diff --git a/TEST-SCENARIOS.md b/TEST-SCENARIOS.md index 17ec586..52108e1 100644 --- a/TEST-SCENARIOS.md +++ b/TEST-SCENARIOS.md @@ -15,6 +15,8 @@ geçerliliğini yitiren senaryo **silinmez**, `deprecated` işaretlenip sebebi y | Senaryo | Test Edilen vNext Feature Seti | Neden Eklendi | Integration Test | Python Test | Durum | |---|---|---|---|---|---| +| **timeout-lab** | Workflow seviyesi `timeout`: state function gövdesindeki **`timeout` bloğu** (`{ key, target, executeAtUtc }`, `transitions[]` girdisi DEĞİL) · bloğun **etkin** timeout'tan beslenmesi (`subFlow.overrides.timeout ?? workflow.timeout`, tek resolver) · `executeAtUtc`'nin kalıcı `InstanceJob.ExecuteAt`'ten okunması, arm anındaki instant · terminal instance'ta bloğun **asenkron cancel-cleanup zincirini beklemeden** düşmesi · bloğun yalnız poll edilen instance'ı anlatması (aktif subflow'a inmemek, parent'ın child'ın deadline'ını devralmaması) · `ResponseShapeVersion` v9→v10 · **ve fire yolunun override'ı onurlandırması**: child kendi tanımında `"timeout": null` taşırken parent'ın override'ının hedefine çekilmesi | İki ayrı kusur, tek fixture. (1) vnext-client-sdk-core#59: instant zaten job satırında duruyordu ama hiçbir okuma yüzeyi taşımıyordu, client geri sayım çizemiyordu. (2) Council `2026-09-21-state-timeout-surfacing` FACT-A: `subFlow.overrides.timeout` **yalnız arm anında** okunuyordu — job zamanında ateşleniyor, handler child'ın KENDİ tanımını okuyup `TimeoutConfigMissing` ile dönüyordu; yani zamanlanmış ama asla varamayan bir deadline. `subflow-orchestration` tam bu şekli taşıyor ve hiçbir test dokunmuyordu. Ayrıca repoda workflow timeout'unu sınayan **hiçbir** senaryo yoktu ve yazılı iki süre de `PT15M` — bir test koşusunda izlenemez (2026-09-21) | `Tests/TimeoutLab` (1 sınıf, 3 test) + `Tests/TimeoutLab/README.md` | — (bilinçli: doğruluk/regresyon senaryosu, gecikme iddiası yok) | ✅ **Aktif — 3/3 yeşil** (lokal runtime, 2026-09-21). **İlk koşuda kendini amorti etti:** senaryo kırmızı düştü ve konseyin kod okumasının kaçırdığı **dördüncü** bir `workflow.Timeout` okuyucusunu açığa çıkardı — `CreateTransitionRecordStep` `$timeout`'u **order 20**'de `Workflow.ResolveWellKnownKey` ile çözüyor ve override'lı child için `TimeoutNotConfiguredForWorkflowException` **fırlatıyor**, yani pipeline `ApplyTimeoutStateStep` (38) düzeltmeye gelemeden ölüyordu. `SetBusy` (19) çoktan commit ettiği için ölçülen belirti "timeout ateşlenmiyor" değil, child'ın **bekleme state'inde sonsuza dek Busy kalması** oldu (job satırı processed işaretli; `e48ac9e2…` tezgâhta 'öncesi' fotoğrafı olarak duruyor). Kanıt test özetinden değil postgres+loglardan: child `child-timedout`/`C`, audit anahtarı **`child-abandoned`** (PARENT'ın override key'i), armlanan `ExecuteAt`'ten ~54 ms sonra; root `root-timedout`/`C` via `root-abandoned`; logda sıfır `TimeoutConfigMissing`. Komşu senaryolar regresyonsuz: `SubflowOrchestration`+`ChainBusy`+`ScheduleAfterAuto` 39/39 | +| **human-task-chain** | `human-task` domain function: aday seçiminin instance'ın KENDİ kolonlarıyla yapılması (`Type IN ('R','P')`, `Status IN ('A','B') AND EffectiveStatus='A'`, `EffectiveStateSubType=6`) · **leaf descent**: seviye-bazında batch, `(domain, flow)` gruplaması, domain sınırı başına tek çağrı · yetkilendirmenin ve `humanTask` metninin **leaf'ten** gelmesi, satır kimliğinin **root'ta** kalması · **SubProcess kimliği**: `P` bağımsız bir akıştır, kendi `Id`'siyle ve **kendi domain'inin** cevabında listelenir, kendi subflow'larına root gibi iner · **yetkilendirme**: kararın leaf state'inin **queryRoles**'undan gelmesi (transition'lar düşürüldü), queryRoles tanımlı değilse **fail-closed** düşme, parent'ın `state_role_overrides`/`transition` override'larının canlı olması (aynı root flow'un iki instance'ı, sadece nerede dinlendikleriyle ayrışıyor), DENY'ın **yalnız başka izinli rol yokken** reddetmesi (`IsAnyRoleAllowed` ilk izinli rolde döner), response cache'inin bir caller'ın listesini diğerine servis etmemesi, SubProcess'in **kendi** leaf'iyle yetkilenmesi · **caller context**: `X-VNext-Cache-Override` ve korelasyon header'larının her domaine geçmesi, truncation'ın agregata yansıması · leaf'in kendi stamped `subflow.transition_role_overrides` haritasından rol çözümleme · `Effective*` invariant'ı: alt akış bitince dörtlünün (state/type/subType/status) parent'ın kendi state'ine dönmesi · projeksiyonun long-poll fingerprint materyali olması | vnext `feature/human-task-function-redesign`: bu uç hiç test edilmemişti ve dört kusur taşıyordu — `EffectiveStateSubType`'ın reset writer'ı yoktu (hayalet human task, herkese, kalıcı), mevcut reset `!HasActiveSubFlow` guard'ı taşımıyordu, descent tek seviye iniyor ve çocuğun tanımını **caller'ın** domain'inde arıyordu (cross-domain çocuk instance'ı listeden tamamen düşürüyordu), iptal edilmiş seviye canlı çocuğun `'A'`'sını ham kolonda tutuyordu (2026-09-17) | `Tests/HumanTaskChain` (2 sınıf, 20 test) | `api-tests/human-task-chain/morph-idm-aggregation-test.py` (20 kontrol, **20/20 geçti** 2026-09-18) + `morph-idm-aggregation.http` (elle sürmek için adım adım) | ✅ **Aktif — 20/20 yeşil** (+ morph-idm uçtan uca 30/30) (dört-domain cross-domain lab, art arda iki koşu, 2026-09-17). Senaryo 3 için lab **dördüncü domain** `credit` (offset 20 → :4221) ile genişletildi; `lab.sh verify` artık ikinci sınırı da (`partner → credit`) doğruluyor. Koşu üç runtime kusuru buldu: fan-out'un içine yerleştirilen özyinelemeli descent aynı DbContext'i paylaşıp 500 veriyordu — DbContext DI scope'una değil **UnitOfWork**'e bağlı ve şema ile anahtarlanıyor, paralel dallar da ambient UoW'yi miras alıyor; her dal artık kendi UoW'sini açıyor (`ExecuteInIsolatedUnitOfWorkAsync`) ve descent paralel kaldı (80 eşzamanlı tam fan-out: 80×200, sıfır istisna), her otomatik geçiş `triggerKind: 10` (DefaultAutoTransition) olmadan rule istiyor, ve rolsüz `cancel` her caller'ı yetkilendirdiği için negatif rol testi anlamsız kalıyordu. SubProcess turu dördüncü bir bulgu ortaya çıkardı, ama bu bir runtime kusuru değil çıktı: state seviyesindeki `subFlow.type: "P"` şekli **geçersiz authoring** olarak yeniden sınıflandırıldı, artık publish anında reddediliyor, ve `ht-a` bir `SubProcessTask`'a dönüştürüldü (bkz. Bilinen Kapsam Açıkları altındaki güncellenmiş satır) | | **error-boundary-lab** | Error boundary çözümlemesi: `CompiledBoundaryChain` Task→State→Global seviye baskınlığı · seviye içi sıra (`EffectivePriority` ASC → specificity DESC; default wildcard'ın 999'a düşmesi) · aksiyonlar abort/retry/rollback/notify/ignore/log · `TaskExecutionEngine` retry döngüsü (`1 + maxRetries`) ve tükenince `ResolveExcluding(Retry)` fallback'i · `BoundaryOutcomeHandler` → fault vs `RequestNextTransition` · `FinalizeTransitionStep`'in boundary transition'ı bitince incident'ı resolve etmesi · `InstanceIncidents` tablosu + denormalize `HasActiveIncident` · state function `incident` bloğu (link tabanlı, ResponseShapeVersion v9, ETag materyali) · `GET .../instances/{id}/incidents/active` (404 = açık arıza yok) · `GET .../instances/{id}/incidents` sayfalama · `metadata.incident` (state bloğuyla aynı şekil) · `POST .../retry` (400 `Instance:100027`, yeniden fault, veriyle kurtarma) · `queryRoles` kapısının incident geçmişine de uygulanması | vnext `feature/incident-table` (issue #865) incident'ları jsonb'den kendi tablosuna taşıdı ve iki yeni client yüzeyi ekledi; ayrıca error boundary bu repoda hiç senaryo olarak yoktu ve `POST .../retry` hiçbir testte çağrılmıyordu (2026-09-06) | `Tests/ErrorBoundaryLab` (6 sınıf, 31 test) | — (bilinçli: davranış/çözümleme senaryosu, eşzamanlılık iddiası yok) | ✅ **Aktif — 31/31 yeşil** (lokal runtime `feature/incident-table`, art arda iki koşu, ~1 dk 46 sn; ilk koşu 2026-09-06, üç davranışsal kusurun düzeltilmesinden sonra 2026-09-07'de yeniden) | | **event-driven-lab** | Olay tetiklemeli start + `triggerType: 3` transition · Dapr Subscription route'u · CloudEvent açma · `IEventMapping` ile iş anahtarı korelasyonu · eşleşmeyen olayın ack'lenmesi | `POST .../instances/events` ucunun **hiç testi yoktu** (council `2026-09-11-route-trace-coverage`, adı konmuş boşluk); ayrıca `Event.Intake` span'i bu ucu ölçüyor ve testsiz route'a span gönderilmez (2026-09-13) | `Tests/EventDrivenLab` (1 sınıf) | — | ✅ Aktif | | **chain-busy** | Accept-time subflow chain reserve **ve başarısız forward'da geri alınması (E31)** · Busy-as-mutex · `$self` shared transition vs `updateData` lifecycle sınırı · start `initial → initial` semantiği · cancel propagasyonu (in-process ↕ distributed) · scheduled transition re-arm | `updateData`-only self-target profil sınırını pinlemek — `target: $self` "hook'ları atla" demek değil (2026-08-17). **E31 (2026-09-12):** post-commit forward istemci hatasıyla düşünce rezervasyon salınmıyordu; üretimde 30 günde 51 mahsur instance (council `2026-09-08-parent-notification-mechanism`, P0) | `Tests/ChainBusy` (5 sınıf) | `api-tests/chain-busy/chain-busy-behaviour-test.py`, `chain-busy-accept-test.py` | ✅ Aktif | @@ -30,6 +32,7 @@ geçerliliğini yitiren senaryo **silinmez**, `deprecated` işaretlenip sebebi y | **account-opening** | Wizard state tipi · çok dallı ürün seçimi · auto gate'lerin geri gönderimi · rol bazlı state function erişimi (`403`) · cancel/exit well-known transition'ları | Template ile gelen ilk referans akış (2025-11-21) | `Tests/AccountOpening` | — | ⚠️ Bilinçli kırmızı — konteynerli ortamda start, `account-type-selection` onEntry task'larında (`notify-state`, `set-or-get-cache`) fault'luyor; testler doğru, boşluğun sinyali olarak kırmızı tutuluyor | | **soap-task-test** | `SoapTask` tipi · SOAP mapping (`SendVipSmsMapping.csx`) · başarı/hata rule'ları ile dallanma | SOAP task tipini uçtan uca doğrulamak 1 (2026-08-13) | — (yalnız `.http`) | — | ⚠️ Integration test yok — kapsam açığı | | **l1-cache-lab** | Component cache versiyon çözümü: `latest` + artifact/major range (`"1"`) referansları · generation-anahtarlı L1 (in-process) cache'in publish görünürlüğü · pinned instance `flowVersion` kararlılığı · publish-only aktivasyon (re-initialize'sız) · view/task referanslarının sıcak cache'de anında yeni versiyona dönmesi | Runtime'a eklenen L1 component cache'in (vnext `feature/component-cache-l1`, 2026-08-20) "versiyon cache'de kaldı" riskini uçtan uca çürütmek; CD sözleşmesinin (publish bitince yeni sürüm MUTLAKA geçerli) regresyon bekçisi | — (bilinçli: publish-akışı doğruluk senaryosu; api-test yeterli) | `api-tests/l1-cache-lab/l1-cache-behaviour-test.py` (`--minor N` ile tekrar koşulabilir) | ✅ Aktif — 18/18 (2026-08-20, lokal L1 runtime) | +| **authorization-chain-lab** | `authorize` fonksiyonunun **aktif korelasyon zinciri** boyunca verdiği cevap: poll edilen instance'ın kendi `queryRoles`'u **VE** altındaki her seviye (en derin leaf'e kadar) — konjonksiyon · parent subflow override'larının **hop başına** ve **çocuğa damgalanmış** haritadan çözülmesi (A'nın B override'ı C'ye taşınmaz; B'nin C override'ı C'de uygulanır; override **REPLACE** eder, merge etmez) · doğrudan adreslenen leaf'in, parent üzerinden erişilen leaf ile **aynı** verdikti vermesi · `authorize`'ın dördüncü hedefi **`ack`** (long-poll acknowledge ön-kontrolü; `IsAwaitingLongPollAck` ile iner, bekleyen yoksa **allowed**) · aktif subflow varken `cancel`/`exit`/`updateData`/state'te müsait shared transition'ın **parent'ta** cevaplanması (execution ile aynı) · `data`'nın içeriğinin inmemesi ama yetkilendirmesinin inmesi · runtime'ın kendi `queryRoles` ve ack **kapılarının kaldırılmış olması** (on yüzeyin hiçbiri 403 dönmüyor ve hiçbiri kapıya sormuyor), buna karşılık `authorize`'ın **reddetmeyi sürdürmesi** — o, gateway'in danıştığı kâhin, runtime'ın yolundaki bir kapı değil — ve rol **çözümlemesinin** (availableTransitions filtresi, state alias, `x-roles` budama, human-task listesi, `CallerScopeHash`) aynen sürmesi | Council `2026-09-22-remove-execution-authorization`: `authorize` üç canlı kusur taşıyordu ve üçü de aynı sonuca çıkıyordu — **ara katmanın danıştığı cevap, runtime'ın uyguladığı kapıdan farklıydı**. (1) `?queryRoles=true` aktif subflow'a kısa devre yapıp kökün kendi grant'larını hiç değerlendirmiyordu, yani tarif ettiği kapıdan **zayıftı**; (2) parent override'ı eşleşince iniş derinlik 1'de duruyor, torunun kapısı hiç çalışmıyordu; (3) override parent'ın TANIMINDAN okunduğu için doğrudan adreslenen leaf'te hiçbir şey bulamıyor ve `authorize` ile state function aynı instance için **zıt** verdikt veriyordu. Ayrıca ara katmanın long-poll ack'i soramaması. Enforcement önce bir geçiş anahtarına alındı, sonra kullanıcı kararıyla kapılar **tamamen kaldırıldı** (2026-09-23): `queryRoles` silinmedi, yeri değişti — tam ve hop başına, `authorize?queryRoles=true` içinde değerlendirilmeye devam ediyor; silinen şey aynı kararın runtime'daki **ikinci** kopyası | `Tests/AuthorizationChainLab` (7 sınıf, 44 + 5 provider testi) + `Tests/AuthorizationChainLab/README.md` + MockLab seed `morph-idm-roles-collection.json` | — (bilinçli: doğruluk senaryosu, eşzamanlılık/gecikme iddiası yok) | ✅ **44/44 yeşil; ayrıca `morph-idm` provider'ıyla 7/7** (2026-09-23, runtime lokal build `f7a053c8` + `claude/remove-authorize-checks-cc40e5`, `VNEXT_BASE_URL=http://localhost:4201`). Suite önce geçiş anahtarına karşı yazılıp iki duruşta da koşuldu; kapılar kaldırıldıktan sonra anahtarı ölçen testler kaldırmayı ölçenlere dönüştürülüp yeniden koşuldu (ortam değişkeni kalmadı). **İlk koşuda üç runtime kusuru bulundu ve üçü de düzeltildi**: (1) `queryRoles` kapısı `EffectiveState` okuyordu — kendi subflow'u olan bir ara seviyede bu bir TORUNUN state anahtarıdır, parent'ın workflow'u onu çözemez, damgalı override eşleşemez ve kapı sessizce workflow kökünün grant'larına düşerdi (`chain.mid-only`, kök onu `chain.admin`'e daraltmışken mid'i 200 okudu); (2) state transition'ları `availableIn` üzerinden okunuyordu — bu liste onlarda boştur ve "boş = her state" kuralı `approve`'u `intake`'te de allowed gösteriyordu, yani oracle **permissive** yönde yanlıştı; (3) `interaction` bloğu state'in BEYANINA göre yayınlanıyordu, gerçekten ack beklenip beklenmediğine göre değil (`ResponseShapeVersion` v10→v11). `morph-idm` provider'ı için suite'in tamamı DEĞİL, yalnız `MorphIdmProviderTests` koşar (7 test, üçüncü bir başlatma + MockLab seed'i): konjonksiyon ve override'lar provider'dan bağımsızdır — bir rol kümesini tüketirler, nereden geldiğine karar vermezler — provider'a özgü olan hangi kümenin geldiğidir. Kanıtlanan: `role`/`x-roles` header'ı morph-idm `204` dediğinde **hayatta kalmaz** (header fallback yok), aynı şey `role` **query parametresi** için de geçerlidir (bu açık bu koşuda prob atılarak bulundu ve `ICallerRoleResolver.AllowsRoleParameterFallback` ile kapatıldı — ölçülen: `?queryRoles=true` 403 iken `?queryRoles=true&role=chain.admin` 200 dönüyordu), hiç role header'ı olmadan servisin cevabı yetki verir, 5xx boş küme değil **403** üretir, ve okuma yüzeyleri `authorize` ile **aynı** provider'dan rol çözer (kapı kalkınca 403 önermesi düştü; yerine teklif edilen transition kümesinin provider'ın cevabına göre değişmesi ölçülüyor). **Bilinen kapsam açığı:** `interaction.longPoll.rule` kolu **authorlanamıyor** — rule kolu runtime'a #936 ile girdi ama vnext-schema (kurulu 0.0.52 ve sibling 1.0.0) `required: [terminate, roles]` + `additionalProperties: false` diyor; `vnext-meta/features.json`'ın "şema roles|rule tekini zorlar" iddiası **yanlış**. Kolun kendisi `LongPollInteractionGateTests` ile unit seviyede pinli ve `authorize?ack=true` aynı gate'e delege ettiği için ara katman onu sorabiliyor | | **role-matrix-lab** | Yetkilendirme yüzeylerinin tutarlılığı: root vs state `queryRoles` (state EZER) · `transition.roles` allowlist / blacklist / predefined (`$InstanceStarter`) · `availableIn` rol daraltması (**AND**) · well-known transition'ların (`cancel`/`updateData`/`exit`) configured key + `kind` ile listelenmesi · master şemada `x-roles` alan budaması · `authorize` function'ının üç hedefi (transitionKey / functionKey / queryRoles) · custom function'da rol denetiminin **kaldırılmış** olması | Provider bazlı caller-role çözümü (`default` \| `morph-idm`) + custom function rol gate'inin kaldırılması; rol setinin KAYNAĞI değişirken grant motorunun davranışının değişmediğini pinlemek (2026-08-19, `feature/caller-role-provider`) | `Tests/RoleMatrixLab` (5 sınıf, 59 test) | — (bilinçli: doğruluk senaryosu, eşzamanlılık değil) | 🆕 Yazıldı, henüz koşulmadı | | **secret-cache-lab** | `ScriptBase.GetSecretAsync` üzerinden in-process secret bundle cache (`ScriptSecretCache`) · bundle başına tek Vault fetch (single-flight) · cache'in request'ler arası (process-wide) yaşaması · TTL süresince bilinçli staleness · TTL dolunca canlı değere tazelenme · script task (type 7) içinden secret erişimi | Script secret fonksiyonlarının her çağrıda vault'a gitmesi yük altında vault'u darboğaza sokuyordu; `Scripting:SecretCache` (TTL 30 sn) geliştirmesinin hem kazancını hem de bayatlık sınırını uçtan uca pinlemek (vnext `claude/scriptbase-secret-cache-y86e03`, 2026-08-20) | — (bilinçli: doğrulama Vault audit log'u + saat ölçümüne dayanıyor, SDK assertion yüzeyinde yok) | `api-tests/secret-cache-lab/secret-cache-behaviour-test.py` | ✅ Aktif — 12/12 (2026-08-20, lokal runtime, TTL 30 sn) | | **fan-out-documents** | `FanOutTask` (TaskType 21) inline mode · `itemsPath` ile koleksiyon çözümü + `ItemKey` türetimi (`id` → `key` → index) · `IFanOutMapping.ItemInputHandler` ile klonlanmış iç task'ın per-item mutasyonu (HTTP url) · `allSettled` join politikası ve `{resultKey}Summary{total,succeeded,failed,timedOut}` üzerinden auto transition dallanması (`RunAutomaticTransitionsStep`, order 90) · **tek-yazim degismezi**: N item → 1 InstanceData sürümü (`SuppressDataApply` + atılan branch context, tek yazım noktası batch'in çıktı adımı) · **`IFanOutMapping.OutputHandler` geri-düşüşü**: mapping yalnız `ItemInputHandler`'ı override eder, çıktıyı runtime'ın `BuildDefaultOutput`'u üretir · iki seviyeli bulkhead (batch-yerel `maxDegreeOfParallelism` × süreç geneli `Workflow:FanOut:MaxConcurrentItems`) · item journal anahtarları `{fanOutTaskKey}#{index}` · sonuçların `join.ordered`'dan bağımsız olarak her zaman index sıralı dönmesi | Runtime'a eklenen FanOutTask'ın (vnext `feature/fanout-task-design`, 2026-08-21) **tek-yazim degismezini** regresyona karşı sabitlemek: bastırma bozulursa fan-out tek bir aggregate üzerinde yarışan N eş zamanlı yazıcıya döner ve tasarımın var oluş sebebi kaybolur. İkincil olarak `allSettled` + özet + auto transition kısmi-başarısızlık kalıbının çalışır bir örneğini pinler (2026-08-21) | `Tests/FanOut` (`FanOutDocumentsTests`, 4 test) | `api-tests/fan-out-documents/fanout-load.py` (bulkhead tavanı + yük altında tek-yazım + straggler oranı) | ✅ 4/4 yeşil (2026-08-22, `ad72158b`) + yük testi PASS: kuyruksuz profil 6/6 (`--instances 4 --items 3 --max-dop 3`), doygun varsayılan profil 5/5 (BULKHEAD bilinçli SKIP — aşağıya bakın). `npm run validate` TaskType 21'i hâlâ reddediyor (`@burgan-tech/vnext-schema@0.0.52` enum'u `"20"`de bitiyor; şema paketi release bekliyor) — publish yolu validate'ten geçmediği için engel değil: SDK `LocalDomainPublisher` component JSON'ını doğrudan `POST /api/v1/definitions/publish`'e atıyor. Item journal assertion'ı **bilinçli olarak yok**: satırlar yalnız monitoring host'unda (4203) görünüyor, SDK stack'i onu başlatmıyor — `fanout-load.py --monitor-url` ile opt-in. **2026-08-22 düzeltmesi:** `DOC-SLOW` straggler route'u `api/fan-out/documents/process-slow` adresindeydi ve MockLab route'ları **PREFIX** ile eşlediği için `documents/process` mock'u tarafından yutuluyordu — yani gecikme hiç uygulanmıyordu ve `fanout-load.py`'nin **straggler oranı metriği jitter ölçüyordu**. Route `api/fan-out/slow-documents/process`'e taşındı, mapping güncellendi, akış 1.0.2'ye bump edildi (integration testler 4/4 yeşil kaldı). Yük testi bundan sonra ilk kez anlamlı sayı üretti ve **iki metrik hatası ortaya çıktı, ikisi de düzeltildi**: (1) `BULKHEAD` metriği doygunlukta **geçersiz** — `sum(durationMs)/wall` "uçuşta geçen süre" varsayıyor ama `FanOutTaskExecutor` item stopwatch'ini slot beklemelerinden **önce** başlattığı için `durationMs` kuyruk süresini de içeriyor (runtime bu yüzden span'e ayrıca `vnext.fanout.item.queue_wait_ms` basıyor); iddia artık yalnızca kuyruksuz profilde kuruluyor (`items <= max-dop` **ve** `instances*items <= ceiling`), aksi halde sebebiyle SKIP. (2) `STRAGGLER` eşiği (`<= 4.0`) yutulmuş route'a kalibreliydi; gerçek straggler ile oran **tasarım gereği ~10**. Eşik 15.0'a çıkarıldı ve **iki taraflı** yapıldı: yeni `STRAGGLER-VAR` tabanı **mutlak** (`en yavaş item >= 1200ms`), çünkü `max/p50` oranı presence detektörü olarak gürültülü — hiç `DOC-SLOW` yokken bile 9.44 üretti | @@ -37,7 +40,10 @@ geçerliliğini yitiren senaryo **silinmez**, `deprecated` işaretlenip sebebi y | **payload-modes** | Request sözleşmesi ↔ şema doğrulaması: payload-mode tespiti (`PayloadModeDetector` + `FormUrlEncodedJsonElementInputFormatter`) · standart zarf (`key`/`tags`/`stage`/`attributes`) ↔ serbest payload ayrımı · `startTransition.schema` **ve** `transition.schema` yollarının ikisi birden · zarf alanlarının iş verisine sızmaması (şemasız transition'da sessiz veri kirlenmesi) · `x-vnext-payload-mode: raw` override'ı · `attributes` eşleşmesinin case-insensitive olması | Şema tanımlı bir transition/start'ta payload'ın **hangi biçimde** gönderildiği validasyon sonucunu değiştiriyordu: mod tespiti tek bir case-sensitive `attributes` property'sine bakıyordu, oysa zarfın her alanı opsiyoneldir — `attributes` içermeyen geçerli bir zarf serbest payload sanılıp **tümüyle** `attributes` altına sarılıyor ve şema iş payload'ı yerine `key`/`tags` alanlarını doğruluyordu (`additionalProperties: false` şemalarda *"All values fail against the false schema"* 400'ü). Şemasız transition'da aynı hata sessizdi: zarf instance data'ya yazılıyordu (2026-08-22, vnext `PayloadEnvelope` ortak zarf sözlüğü) | `Tests/PayloadModes` (2 sınıf, 24 test) | — (bilinçli: request sözleşmesi doğruluk senaryosu, eşzamanlılık iddiası yok) | ✅ **Aktif — 24/24 yeşil** (2026-08-22, lokal runtime). Düzeltme öncesi runtime'a karşı **tam 4 test kırmızı** (start: envelope-only + PascalCase `Attributes`; transition: envelope-only + şemasız transition'da `key`'in instance data'ya yazılması) — regresyon iğnesi doğrulandı. Kullanıcının bildirdiği üç kanonik biçim (`{key,attributes}`, `{attributes}`, serbest gövde) düzeltme öncesinde de geçiyordu; testler "üçü de aynı sonucu üretir" sözleşmesini sabitler. Akış **hiç task içermez** — MockLab/execution host/worker bağımlılığı yok. **Aynı geliştirme altında ikinci bir defect düzeltildi:** kök düzeyindeki `required` hatası hiyerarşik ağaç düzleştirilirken düşüyordu (`JsonSchemaValidationMapper.FlattenErrors` bir düğümün *kendi* hatalarını, çocukları varsa atıyordu — `additionalProperties:false` + iç içe obje olan her şemada kök hatası TEK hataydı), istemci `"errors":{}` ile **hangi alanın hatalı olduğunu öğrenemiyordu**; boş hata listesi ayrıca yanıtı RFC7807 ProblemDetails'e düşürerek iki farklı gövde biçimi yaratıyordu. Artık tek biçim + alan düzeyinde `members`/`message` | | **script-perf-lab** | Script compile cache hit yolu (`CSharpEvaluator._typeCache`) · `scripts.helpers` çok üyeli helper set (A7) · instance-data append zinciri (`JsonData.Merge`/`NormalizedJson`, B9 O(n²) profili) · `FanOutTask` inline branch klonu (`CreateParallelBranch`, B6) · Katman 0 metrikleri (`script_compilations_total{result}`, `script_execution_duration_seconds{script_type}`) | Katman 0 ölçüm altyapısının makro baseline'ı — Katman 1-3 compiler/serialization optimizasyonlarının gerçek-yük önce/sonra referansı (2026-08-23, vnext `feature/script-perf-katman0`) | `Tests/ScriptPerfLab` (1 test) | `api-tests/script-perf-lab/perf-load.py` (soğuk/sıcak faz + p50/p95/p99 + /metrics snapshot) | ✅ Aktif — K1+K2 önce/sonra kayıtlı; COW+canonicalizer 37/37 integration + kill-switch canlı testli (2026-08-23) | | **schedule-after-auto** | Pipeline epilogue sıralaması (`LifecycleOrder.Auto` 80 → `LifecycleOrder.Schedule` 90) · `ScheduleTransitionsStep`'in `Directives.NextTransition` guard'ı (auto kazanan varsa **hiç** arm etmez: Dapr job yok, `InstanceJob` satırı yok) · state function'ın `kind: "scheduled"` girdileri + `executeAtUtc` · auto kazanan yokken scheduled transition'ın eskisi gibi arm edilip **gerçekten ateşlenmesi** · `CancelScheduledJobsStep` (39) churn'ünün ortadan kalkması | Eski sıralamada Schedule (80) timer'ı arm ediyor, Auto (90) kazanan seçiyor, zincirlenen hop da o timer'ı hemen siliyordu — auto'nun kazandığı her hop'ta boşuna enqueue + persist + cancel. Sıralama takas edildi (vnext `feature/schedule-after-auto`, plan `docs/superpowers/plans/2026-09-02-schedule-after-auto.md`, 2026-09-03). Senaryo hem yeni davranışı hem de "auto kazanmazsa hiçbir şey değişmedi" tarafını pinler; iki sıralama **dinlenme durumunda ayırt edilemediği** için auto hop'unun `onExecute`'u bilinçli ~2.5 sn gecikir ve test o pencerede armed girdinin **hiç** oluşmadığını gözler | `Tests/ScheduleAfterAuto` (`ScheduleAfterAutoTests`, 2 test) | — (bilinçli: sıralama/doğruluk senaryosu; eşzamanlılık iddiası yok) | ✅ **Aktif — 2/2 yeşil** (2026-09-03, lokal runtime `702a03b6`, iki koşu üst üste, ~24 sn) | -| **cross-domain-lab** | Cross-domain transport: `ServiceDiscovery:Provider=dapr` (`DaprDomainDiscoveryProvider`, registry `appId` override → `vnext-app-partner`) · Dapr service invocation shell (`DaprRemoteTransport`) · cross-domain **SubFlow** start / `internal/subflow-forward` / parent resume (`ResumePipelineAsync`) · trigger task'ları **11 Start · 12 DirectTrigger · 13 GetInstanceData · 14 SubProcess · 15 GetInstances (`SetFilterSpec`) · 19 GetInstance** (`useDapr:true`, `config.domain:"partner"`) · fonksiyon descent'i `state` / `view` / `schema` / `authorize` (partner rol filtresi) / `data?extensions=` (`RemoteInstanceQueryAppService`) · `data` gövdesinin parent'ta kalması (pinlenmiş runtime kararı) | Cross-domain adres çözümlemesi Discovery HTTP'sinden Dapr Name Resolution'a taşındı (vnext `feature/dapr-name-resolution`, 2026-09-03); repoda hiç cross-domain örnek yoktu. İkinci domain (`partner/`, `vnext.partner.config.json`) ve üç-domain lokal lab (`labs/cross-domain/`) bu senaryoyla geldi. Plan: `labs/cross-domain/VNEXT-BUILD-PLAN.md` | `Tests/CrossDomainLab` (`SubflowDescentTests` 6, `TriggerTaskTests` 5) — `VNEXT_PARTNER_BASE_URL` yoksa **skip** | — (yük testi sonraki faz) | ✅ **Aktif — 11/11 yeşil** (2026-09-03, lab: üç domain de lokal `dapr-nr` imajları + Dapr 1.18.0, ~1.7 dk). Rollback tatbikatı `VNEXT_LAB_DISCOVERY_PROVIDER=http` ile de 11/11 (2026-09-04; `Remote*` düz HTTP `vnext-app-partner:5000`, `useDapr` task'ları Dapr'da). **Discovery endpoint cache doğrulaması (2026-09-09, `http` provider, 11/11 yeşil):** core tek bulk okumayla 3 domain'i cache'e yazdı (`50002`), partner marker'ı görüp **hiç** bulk okuma yapmadı ama 13 çözümlemesinin 13'ünü paylaşılan cache'ten aldı — kümede pencere başına tek okuma. Elastic APM `Discovery.Resolve/*`: 79 span, cache 78 / registry 1; L2'den bir kayıt silinip L1 süresi beklendiğinde aynı domain **registry 100.7 ms → cache 0.07-0.6 ms**. `POST utilities/discovery/refresh` açık pencerenin içinde senkron yeniden okudu (`outcome: Refreshed`). Not: lab template'inin `AdditionalSources`'ında `BBT.Workflow.Pipeline` yok — eklenmezse `Discovery.Resolve` span'i sessizce düşer; template ayrıca yalnız OpenObserve'e export eder, Elastic için collector'a `otlp/elastic` + elasticsearch/apm-server gerekir. Bilinen: vnext-schema 0.0.52 `useDapr`'ı yalnız task 15/19'da tanır → `core/Tasks/cross-domain-lab/` 11/12/13/14 dosyaları `npm run validate`'te "then schema" hatası verir (runtime alanı okur, alan bilinçli korunuyor); `partner/` validate kapsamı dışında | +| **cross-domain-lab** | Cross-domain transport: `ServiceDiscovery:Provider=dapr` (`DaprDomainDiscoveryProvider`, registry `appId` override → `vnext-app-partner`) · Dapr service invocation shell (`DaprRemoteTransport`) · cross-domain **SubFlow** start / `internal/subflow-forward` / parent resume (`ResumePipelineAsync`) · trigger task'ları **11 Start · 12 DirectTrigger · 13 GetInstanceData · 14 SubProcess · 15 GetInstances (`SetFilterSpec`) · 19 GetInstance** (`useDapr:true`, `config.domain:"partner"`) · fonksiyon descent'i `state` / `view` / `schema` / `authorize` (partner rol filtresi) / `data?extensions=` (`RemoteInstanceQueryAppService`) · `data` gövdesinin parent'ta kalması (pinlenmiş runtime kararı) · **discovery endpoint cache warm-up**: registry'nin Domain-scope `domain-list` function'ından tek okuma (`DiscoveryRegistryClient.ListAllAsync` → `DiscoveryCacheRefresher` → L1/L2), `POST utilities/discovery/refresh` | Cross-domain adres çözümlemesi Discovery HTTP'sinden Dapr Name Resolution'a taşındı (vnext `feature/dapr-name-resolution`, 2026-09-03); repoda hiç cross-domain örnek yoktu. İkinci domain (`partner/`, `vnext.partner.config.json`) ve üç-domain lokal lab (`labs/cross-domain/`) bu senaryoyla geldi. Plan: `labs/cross-domain/VNEXT-BUILD-PLAN.md` | `Tests/CrossDomainLab` (`SubflowDescentTests` 6, `TriggerTaskTests` 5 — `VNEXT_PARTNER_BASE_URL` yoksa **skip**; `DiscoveryWarmUpTests` 3 — `VNEXT_DISCOVERY_BASE_URL` yoksa veya cache kapalıysa **skip**) | — (yük testi sonraki faz) | ✅ **Aktif — 11/11 yeşil** (2026-09-03, lab: üç domain de lokal `dapr-nr` imajları + Dapr 1.18.0, ~1.7 dk). Rollback tatbikatı `VNEXT_LAB_DISCOVERY_PROVIDER=http` ile de 11/11 (2026-09-04; `Remote*` düz HTTP `vnext-app-partner:5000`, `useDapr` task'ları Dapr'da). **Discovery endpoint cache doğrulaması (2026-09-09, `http` provider, 11/11 yeşil):** core tek bulk okumayla 3 domain'i cache'e yazdı (`50002`), partner marker'ı görüp **hiç** bulk okuma yapmadı ama 13 çözümlemesinin 13'ünü paylaşılan cache'ten aldı — kümede pencere başına tek okuma. Elastic APM `Discovery.Resolve/*`: 79 span, cache 78 / registry 1; L2'den bir kayıt silinip L1 süresi beklendiğinde aynı domain **registry 100.7 ms → cache 0.07-0.6 ms**. `POST utilities/discovery/refresh` açık pencerenin içinde senkron yeniden okudu (`outcome: Refreshed`). Not: lab template'inin `AdditionalSources`'ında `BBT.Workflow.Pipeline` yok — eklenmezse `Discovery.Resolve` span'i sessizce düşer; template ayrıca yalnız OpenObserve'e export eder, Elastic için collector'a `otlp/elastic` + elasticsearch/apm-server gerekir. Bilinen: vnext-schema 0.0.52 `useDapr`'ı yalnız task 15/19'da tanır → `core/Tasks/cross-domain-lab/` 11/12/13/14 dosyaları `npm run validate`'te "then schema" hatası verir (runtime alanı okur, alan bilinçli korunuyor); `partner/` validate kapsamı dışında | **domain-list warm-up (2026-09-17, `DiscoveryWarmUpTests` 3/3 yeşil):** runtime'ın bulk okuması sayfalı instance listesinden registry'nin `domain-list` function'ına taşındı (vnext `feature/discovery-domain-list-bulk-read`, geri dönüş yolu yok). Lokal iki-domain kurulumu (core :4201 + discovery :4221, `run-docker.sh up`, lokal binary'ler, registry paketi `@burgan-tech/vnext-discovery-runtime@0.0.7`): giden istek tam olarak `GET /api/v1/discovery/functions/domain-list` — `page=`/`filter=` **yok** — ve `Refreshed` ile 11 domain Redis'e yazıldı (`vnext||discovery:domain:v1:*` + `discovery:bulk:v1:refreshed-at`). Negatif: registry süreci öldürülünce refresh `Failed` döndü ve 12 kayıt **silinmeden** kaldı (fail-open). Ortam kusuru: `Kestrel:GrpcPort` appsettings'te 4212'ye sabit ve offset'lenmiyor → ikinci domain'in Execution host'u core'unkiyle çakışır, `Kestrel__GrpcPort` elle verilmeli (offset 10 ayrıca kendi HTTP portuyla çakışır). + +| **task-invocation-lab** | Task invocation routing (`Workflow:TaskInvocation:Modes`/`DefaultMode`, `ITaskInvocationRouter.Resolve`'ın per-type host config → configured default → local-invoker capability gate sırası) · beş "wire" task tipinin Orchestration üzerinde in-process (Local) çalışması: Http (tip 6), DaprService (tip 3), Soap (tip 16), StateStore (tip 17, set/get round-trip aynı statik Dapr anahtarına), CacheAside (tip 18, `til-cache-source` üzerinden miss-then-hit round-trip) · error-boundary handler seçiminin task'ın SONUCUNDAN yapılması: HTTP 500 → task-level Notify, client `timeoutSeconds` vs. yavaş bir MockLab route → task-level Rollback (schema'da geçerli ama `CompiledBoundary.Compile`'ın hiç okumadığı `errorBoundary.onTimeout` yerine `onError`+`errorCodes:["Task:Http:Timeout"]` üzerinden), SOAP fault → task-level Abort (instance fault) · `TaskInvocationResult`/`StandardTaskResponse` şeklinin (`taskType`, `statusCode`, metadata anahtar kümesi) Local ve Remote routing arasında DEĞİŞMEMESİ — aynı test dosyasının host'u zorla Remote'a çevrilip yeniden koşturulmasıyla doğrulanır | vnext `1007-…` dalı beş task tipini ayrı bir Execution servisinden Orchestration host'una taşıdı; **DaprService, Soap ve StateStore/CacheAside'ın üçü de bu repoda daha önce HİÇ bileşeni yoktu** — ilk kez bu senaryoyla örnekleniyor. `tilTaskType` alanı ayrıca bu dalda routing'e bağlı olarak iki kere yanlış damgalanmıştı (build raporu) ve `InstanceTasks` günlüğüne serileştiriliyor, bu yüzden ayrı bir parity test sınıfı bu alanı hedefliyor | `Tests/TaskInvocationLab` (3 sınıf, `TaskTypeInvocationTests`/`ErrorBoundaryInteractionTests`/`ResultModelParityTests`) — `ResultModelParityTests`'in değeri iki ayrı koşudan gelir (Local varsayılan + `Workflow__TaskInvocation__Modes__*=Remote` ile zorlanmış), tek koşu yeterli kanıt değildir | — (bilinçli: routing/parity senaryosu, eşzamanlılık iddiası yok) | 🆕 **Yeni — henüz koşulmadı** (2026-09-19, testler yazıldı ve derlendi; `dotnet test` bilerek çalıştırılmadı, paylaşılan lokal runtime'da eşzamanlı başka bir entegrasyon koşusu vardı) | +| **subflow-start-failure-lab** | Post-commit `StartSubflowJob` başarısız olduğunda parent'ın gerçekten kurtarılabilir olup olmadığı: `parent-subflow-state` (`stateType: 4`, `subFlow.type: "S"`) → `HandleSubFlowStep` (order 70) `InstanceCorrelation`'ı KENDİ UoW'unda commit'ler → post-commit `StartSubflowJobHandler`/`SubflowStarter` child'ın start transition'ını çağırır ve child'ın şeması zorunlu kıldığı `mustProvide` alanı (parent'ın `ISubFlowMapping`'i `Instance.Data`'dan kasıtlı göndermiyor) yüzünden schema validation'da başarısız olur → `TransitionRunner.CompensateFailedCoordinationAsync` parent'ı fault'lar → `POST .../retry` (düzeltilmiş `mustProvide` gövdede) child'ı gerçekten yaratıp yaratmadığı | Sibling dal `fix/stranded-busy-and-dead-flag-cleanup`'taki bir code review, post-commit `StartSubflowJob` hatasında parent'ı fault'lamanın dokümantasyonda "görünür ve **retry edilebilir**" diye tanımlanmasına karşı CRITICAL bir bulgu bildirdi: `HandleSubFlowStep`'in correlation'ı child hiç yaratılmadan ÖNCE commit'lemesi, eski `InstanceRetryAppService.RetryFaultedInstanceAsync`'in `instance.HasActiveSubFlow` dalının parent'ı unfault'layıp commit'lemesi, incident'larını resolve etmesi, SONRA var olmayan child'ı gateway üzerinden sorgulaması ve 404 alması. **Ölçüldü — CRITICAL'ın iki yarısı da doğrulandı, ama ayrı ayrı:** fault/incident yarısı ÇALIŞIYORDU, retry yarısı ÇALIŞMIYORDU (`HTTP 404 Instance:100013`, parent unfault'lanıp `parent-subflow-state`'te sıkışık kalıyordu, fault geçmişi siliniyordu). **Runtime'da düzeltildi (bu görev):** `InstanceRetryAppService` artık child'ı parent'a DOKUNMADAN ÖNCE prob'luyor; "not found" ise parent'ı yeniden arm ediyor (unfault → Busy, canlı bir bloklayan SubFlow parent'ının her zaman taşıdığı invariant'ı taklit ederek) ve AYNI correlation için subflow start'ı yeniden çalıştırıyor (`ISubflowStarter`'ın `StrictIdempotency`'siyle korelasyonun kendi önceden üretilmiş `SubFlowInstanceId`'si üzerinden idempotent — ikinci bir correlation yaratmıyor, parent'ın transition'ını tekrar çalıştırmıyor). Restart'ın kendisi başarısız olursa parent SESSİZCE Active bırakılmıyor: `PostCommitParentMutationService.FaultAsync` üzerinden (aynı `TransitionRunner.CompensateFailedCoordinationAsync`'in kullandığı yol) yeniden fault'lanıyor, taze bir incident'la — bir sonraki retry aynı yoldan tekrar dener. Test artık gerçek kurtarmayı kanıtlıyor: `mustProvide` `Instance.Data`'dan asla gelmiyor (ilk otomatik start hâlâ aynı şekilde başarısız oluyor), ama retry İSTEĞİNİN GÖVDESİNDEN (`{"attributes":{"mustProvide": ...}}`) okunuyor — `InstanceRetryAppService`'in restart yolu bunu `ScriptContext.Body`'ye kadar taşıyor, tıpkı başka bir transition'ın `OnExecute` task'larının retry-verisi göreceği yol gibi. **Ayrıca ölçüldü**: en ucuz form denendi ilk önce (`subFlow.process` gerçek key + hiç yayınlanmamış versiyon `9.9.9`) — bu senaryoyu YENİDEN ÜRETMEDİ (bkz. README), schema-validation formuna geçildi; parent `sync=true` ile başlatılırsa child'ın schema hatası PARENT'ın kendi start cevabına sızıyor, test bilerek `sync=false` kullanıyor. `failed-subflow-start-is-faulted-but-not-retryable` id'li bir `vnext-meta` known-issue artık AÇILMAMALI — açık kapandı; `pre-reserved-job-failure-can-strand-busy` hâlâ farklı, zaten düzeltilmiş bir kusuru belgeliyor | `Tests/SubflowStartFailureLab` (`SubflowStartFailureLabTests`, 2 test) | — (bilinçli: tek senaryo, yük/eşzamanlılık iddiası yok) | ✅ **YEŞİL — 2/2**, ve geniş regresyon seti **75/75** (2026-09-22, lokal core runtime `:4201`, rebuild sonrası). Test 1 fault+incident'ı kanıtlıyor. Test 2 (`RetryingAFaultedSubflowStartRecoversTheInstance`) artık GERÇEK kurtarmayı kanıtlıyor: retry `< 400` dönüyor, parent `F` değil ve `parent-subflow-state`'te (correlation açık, artık canlı bir child'ı bekliyor), state function child'a inip `state="child-initial"`/`status="A"` raporluyor. Postgres'te doğrulandı: parent `Status='B'`, `HasActiveIncident=false`; correlation `IsCompleted=false`, AYNI `SubFlowInstanceId`; child şemasında (`subflow_start_failure_lab_child.Instances`) o id ile `Status='A'`, `CurrentState='child-initial'` satırı artık MEVCUT | 1 Gerekçe git geçmişinde kayıtlı değil (commit mesajı `updated`); senaryonun kendi içeriğinden çıkarıldı. Doğrusunu bilen varsa bu satırı düzeltsin. @@ -48,6 +54,24 @@ içeriğinden çıkarıldı. Doğrusunu bilen varsa bu satırı düzeltsin. Her senaryonun ayrıntısı kendi README'sinde / test sınıfının XML özetinde durur. Öne çıkanlar: +### timeout-lab + +Üç akış, iki bacak. `timeout-lab-root` workflow seviyesinde `PT20S`'lik bir timeout taşır ve +`root-waiting`'de hiçbir şey yapmadan bekler — instance'ı yalnız deadline hareket ettirir, dolayısıyla +kırmızı bir test tek bir sebebe indirgenir. `timeout-lab-parent` → `timeout-lab-child` çifti ikinci +bacak: child **kendi tanımında `"timeout": null`** taşır, deadline'ı tamamen parent'ın +`subFlow.overrides.timeout`'undan gelir. Bu, FACT-A'nın regresyon fixture'ıdır; child'a kendi +timeout'unu vermek regresyonu silmek olur. + +Süre neden `PT20S`: repodaki yazılı iki timeout da `PT15M` ve bir koşuda izlenemez. +**`subflow-orchestration-parent.json`'daki `PT15M` kısaltılmamalı** — runtime artık override'ı +onurlandırdığı için o senaryonun child'ları suite'in ortasında iptal olmaya başlar. + +Bilinçli olarak denetlenmeyen: deadline'ın aktiviteyle sıfırlanması. Sıfırlanmıyor — +`timer.reset` şemada zorunlu, runtime'da **hiçbir yerde okunmuyor**, dolayısıyla süre instance +başlangıcından itibaren mutlak bir bütçedir (`vnext-meta/known-issues.json` → +`workflow-timeout-reset-not-implemented`). + ### error-boundary-lab İki workflow (`error-boundary-lab`, `error-boundary-lab-global`) ve bir hub state üzerinden her @@ -247,6 +271,22 @@ okuma yalnız cancel girdilerini gösterir — bu bir hata değil, rol filtresid harici-stack modunda SDK hook'u çağrılmadığı için `CrossDomainLabFixture`'da yayınlanır. Lab tarafında `nameformat` çözücüsü daprd 1.16.x'te yok; `appconfig` açıkça `mdns` pinler. +`DiscoveryWarmUpTests` aynı klasörde ama farklı bir şeyi ölçer: transport'u değil **adres kaynağını**. +Runtime, discovery endpoint cache'ini registry'nin Domain-scope `domain-list` function'ından tek bir +istekle doldurur (eskiden sayfalı instance listesini gezip projeksiyonu istemcide türetiyordu; geri +dönüş yolu bilinçli olarak bırakılmadı). Üç AC: registry'nin düz `items[]` + dört alan sözleşmesi +(sayfalama zarfı **olmamalı**), `POST utilities/discovery/refresh` → `Refreshed` (bu sonuç "okuma +başarılı ve liste boş değil ve her kayıt yazıldı" demektir — refresher boş listeyi `Failed` sayar), ve +yeni bir kayıttan sonra listenin büyüyüp warm-up'ın hâlâ başarılı olması (registry'nin 24 saatlik +function cache'i kayıtta evict ediliyor). + +Kritik detay: cache **yalnız `Provider=http`'de** register edilir — Dapr provider app-id'yi isimlendirme +konvansiyonundan türetir ve varsayılan yolunda registry'ye hiç gitmez. Bu yüzden lab'ın varsayılan +`dapr` provider'ında refresh `disabled` döner ve testler kendilerini skip eder; `partner` domain'i +gerekmez, `core` + registry yeter. Registry `@burgan-tech/vnext-discovery-runtime` **>= 0.0.7** +taşımalı: `domain-list` ilk o sürümde var, öncesinde 404 gelir ve warm-up her pencerede başarısız olur +(`DomainListEndpointMissing`, EventId 50039) — cache soğuk kalır, çözümlemeler canlı lookup'a düşer. + Detay: [`tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md`](tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md) · lab: [`labs/cross-domain/README.md`](labs/cross-domain/README.md) @@ -330,4 +370,6 @@ python3 api-tests/fan-out-documents/fanout-load.py --publish --instances 20 --it | `incident.retryCount` her zaman 0 | Retry sayısı client'a hiç ulaşmıyor | Engine, retry policy'yi boundary aksiyon sonucuna iliştirmiyor; deneme sayısı yalnız instance verisinden okunabiliyor | | `ignore`/`log` incident yazmıyor ve hook'un kalanını atlıyor | Dokümante edilen "informational, resolved incident" niyeti gerçekleşmiyor; aksiyondan sonraki task'lar koşmuyor | Devam-tipi sonuç boundary aksiyonu iliştirmeden dönüyor, pipeline step incident yazan dala girmiyor | | Kurtulan instance hâlâ aktif incident bildiriyor | Başarılı retry sonrası `hasActiveIncident` true kalıyor (abort iki incident bırakıyor, `Unfault()` yalnız birini resolve ediyor) | "Neden takıldı?" ekranı sağlıklı instance'ta bayat sebep gösterir | +| `data` built-in function'ı aktif subflow'a inmiyor | Root'un `data`'sı kendi attribute'larını döner (`HT-A step`) oysa state body leaf'i tarif eder (`ht-c-human`); üstelik state body'sinin kendi `data.href`'i root'u adresler, yani istemci verilen linki takip edince baktığı state'ten başka bir instance'ın verisini okur | `state`, `authorize`, `view`, `schema`, `master`, `extensions` iniyor; `data` inmiyor. İstemcinin case verisini mi leaf'in çalışma kopyasını mı istediği ürün kararı, o yüzden düzeltilmedi — `TheStateFunctionDescendsButTheDataFunctionDoesNot` bugünkü davranışı pinliyor ki sessizce değişmesin. Ölçüm 2026-09-18, cross-domain lab | +| ~~State seviyesindeki SubProcess (`subFlow.type: "P"`) senkron başlatılamıyor~~ — ÇÖZÜLDÜ: kusur değil, geçersiz authoring | (Tarihçe) `?sync=true` ile `409 conflict.Instance:100031` ("transition ... cannot be accepted while another transition is queued or executing"); parent spawn state'inde Busy takılı kalıyor, başlatılan çocuk öksüz kalıyordu. Bu artık üretilemez: platform kuralı netleşti — **state yalnız SubFlow başlatır**, `state.subFlow.type` yalnız `"S"` olabilir; bir SubProcess kendi `SubProcessTask`'ı (task `type: "14"`) ile başlar, executor'ı çocuğu başlatıp korelasyonu kendisi kurar, ve bir SubProcess başka bir SubProcess başlatamaz. Runtime artık state seviyesinde `"P"`'yi **publish anında reddediyor** (`WorkflowValidator.ValidateStateSubFlowType`) — yani bu şekil bir runtime kusuru değil, authoring hatasıydı | (Tarihçe) `HandleSubFlowStep` `P` için `ContinueParent` post-commit işi kuruyordu; `PostCommitTransitionCoordinator` devamı `InlineContinuationStrategy` ile dispatch ediyor, o da `IsPreReserved = currentContext.IsPreReserved` kopyalıyordu. Senkron yolda ilk stage `ReserveAsync` ile `OwnsStatus = true` alıyor ama `IsPreReserved` **false** kalıyor, Settle de bu dalda atlanıyor → devam `TransitionRunner`'da yeni bir stage olarak pipeline'a **baştan** giriyor ve kendi sahip olduğu Busy'ye takılıyordu. O zamanki aday düzeltme tek satırdı: `InlineContinuationStrategy`'de `IsPreReserved = currentContext.IsPreReserved \|\| currentContext.OwnsStatus` — bu satır **incelendi ve reddedildi**. Agent-council oturumu `2026-09-22-sync-subprocess-continuation-admission` platform kuralını `VOID` kapattı; sonraki bir review, genişletmenin ilişkili başka bir yerde de güvensiz olduğunu gösterdi çünkü bir instance satırının `Busy` olması bu execution'ın o Busy'yi **sahiplendiğinin** kanıtı değildir. `ht-a` buna göre onarıldı: `ht-a-spawn` artık düz bir intermediate state (`stateType: 2`), `ht-a-spawned` transition'ı gerçek bir `SubProcessTask` taşıyor (`core/Tasks/human-task-chain/ht-a-spawn-process.json`), fire-and-forget semantiği korunuyor. `wf sync` artık temiz publish ediyor (`core: 202 ok`); async-only workaround artık gerekli değil | | Yeniden fault eden retry'dan sonra statü yanıtla çelişiyor | Retry gövdesi `"status":"F"` derken instance `Active` yerleşiyor ve artık `retry` edilemiyor (`Instance:100027`) | Ambient scope'un bayat Active'i, `RequiresNew` scope'un yazdığı Faulted'ı eziyor (retry yolunda cross-UoW last-writer-wins) | diff --git a/api-tests/human-task-chain/README.md b/api-tests/human-task-chain/README.md new file mode 100644 index 0000000..ea2fabe --- /dev/null +++ b/api-tests/human-task-chain/README.md @@ -0,0 +1,148 @@ +# human-task-chain — morph-idm-api end to end + +## What this covers + +`tests/Core.IntegrationTests/Tests/HumanTaskChain` proves **one domain's** answer. This proves the +**client's** answer — the whole path, together: + +``` +client → morph-idm-api → discovery domain-list → per-domain human-task → leaf descent → leaf +``` + +It is the only place that path is exercised as a whole, and the only place the aggregation's own +behaviour (domain fan-out, per-domain failure isolation, `domainName` attribution) is checked. + +| File | What it is | +|---|---| +| `morph-idm-aggregation-test.py` | Automated, 20 checks, exit code 0/1. Standard library only. | +| `morph-idm-aggregation.http` | The same path hop by hop, for driving by hand when something is wrong. | +| `build-human-task-chain.py` | Generates the scenario's six workflow components. See the [scenario README](../../tests/Core.IntegrationTests/Tests/HumanTaskChain/README.md). | + +## Prerequisites + +### 1. The four-domain lab + +```bash +bash labs/cross-domain/lab.sh images # runtime images from ../vnext working tree +bash labs/cross-domain/lab.sh up # core :4201 partner :4211 credit :4221 discovery :4231 +``` + +### 2. morph-idm-api **on the lab's Docker network** + +This is not a convenience — it is the only working topology. Discovery hands out +**container-internal** base URLs (`http://vnext-app-core:5000`), because that is what the domains use +to reach each other over Dapr. A morph-idm running on the host cannot resolve those names and every +fan-out call dies with `nodename nor servname provided`; the endpoint still answers **`200` with an +empty list**, so the symptom looks like "no tasks", not like a broken deployment. + +```bash +cd ../morph-idm-api/api +docker build -t morph-idm-api:lab -f Dockerfile . + +docker run -d --name morph-idm-api --network vnext-development -p 5288:5000 \ + -e "Discovery__BaseUrl=http://vnext-app-discovery:5000" \ + -e "ConnectionStrings__DefaultConnection=Host=vnext-postgres;Port=5432;Database=mocklab;Username=postgres;Password=postgres" \ + morph-idm-api:lab +``` + +The committed `Discovery:BaseUrl` is already `http://localhost:4231`, which is right for a host-run +process; the override above is what the container needs. The connection string points at the lab's +Postgres — create the database once with +`docker exec vnext-postgres psql -U postgres -c "CREATE DATABASE mocklab;"`. + +`Dockerfile` already passes `-p:SkipBuildUI=true`; a host build needs that flag explicitly +(`dotnet build BBT.IdmUtils.sln -p:SkipBuildUI=true`) or it fails on `npm run build`. + +## Run + +```bash +python3 api-tests/human-task-chain/morph-idm-aggregation-test.py \ + --core http://localhost:4201 \ + --partner http://localhost:4211 \ + --credit http://localhost:4221 \ + --discovery http://localhost:4231 \ + --idm http://localhost:5288 +``` + +All five URLs default to the values above, so the bare command works against a standard lab. + +## What it checks + +| Step | Assertion | +|---|---| +| 1 | discovery's `domain-list` carries core, partner and credit | +| 2–3 | one chain started per scenario (`hops` 0, 2, 3, 5) plus one `mode=process` root that spawns a SubProcess into partner, all waited to their rest points | +| 4 | each row carries the **leaf's** title (`HT-A` / `HT-C` / `HT-D` / `HT-F step`) and the **root's** workflow | +| 5 | partner and credit list none of this run's roots — an **`S`** child is represented by its root, never listed twice — while partner **does** list the spawned **`P`** SubProcess on its own, addressed by its own `id` and carrying `HT-F step`, the text of the leaf it descended to | +| 6 | a caller holding another role sees none of the chain | +| 7 | the aggregation carries all four titles, every row names its `domainName`, every row is flagged `vnextTask` | +| 8 | the caller's context reaches every domain (`X-VNext-Cache-Override` changes a repeat read from a cache hit into a rebuild; `x-request-id` appears in the domain runtime's own log) and the domains' truncation comes back (body flag, named domains, and the same header on morph-idm's response) | + +Step 5 is the one that would catch a selection predicate that started matching `S` children, or a +SubProcess addressed by the business `Key` it inherits from its parent (which would point a client +at the root, in another domain); step 4 catches a descent that stopped early or read the root's text. + +**Cache:** every read sends `X-VNext-Cache-Override: true`. The response cache has a 60 s TTL and no +validation query, so polling through it waits out the TTL on a pre-change answer and then reports a +*wrong* list rather than a stale one — a far more misleading failure. + +## Pass criterion + +`PASSED — 30 checks`, exit code 0. Last run 2026-09-18 against the four-domain lab: 30/30. + +## Known environment traps + +Both were hit while writing this and both look like "the feature is broken": + +- **Discovery's bulk list and its per-domain entries can disagree.** `domain-list` is served from + `vnext||custom:discovery:domains:active` in Redis while `domain-lookup` reads a per-domain key. A + registration made while an older discovery package was installed does not invalidate the bulk key, + so `domain-list` can answer with entries from another environment entirely while `domain-lookup` + is correct. Symptom: morph-idm fans out to hostnames that do not exist. Fix in the lab: + `docker exec vnext-redis redis-cli del 'vnext||custom:discovery:domains:active'`. +- **A negative lookup is cached with the same TTL as a positive one.** A domain that was down when + first looked up stays invisible for the whole TTL (~17 h observed) even after it registers. + Symptom: `lab.sh verify` reports `X NOT registered` while the registry clearly holds it. Same fix, + on that domain's key. + +- **A state-level SubProcess cannot be started with `sync=true`.** `?sync=true` on a `mode=process` + start answers `409 conflict.Instance:100031` and leaves the root stranded Busy in `ht-a-spawn` + with an orphaned child. The post-commit `ContinueParent` continuation re-enters the pipeline with + `IsPreReserved = false` while the instance is still Busy from the stage that continuation belongs + to; the async path escapes it only because a job re-entry sets that flag independently. Both the + script and the `.http` walkthrough start that case asynchronously for this reason. Recorded in + `TEST-SCENARIOS.md` § Bilinen Kapsam Açıkları. + + + +## Fan-out bounds on both sides (2026-09-18) + +The two repositories cap different things, and only their product meets the database. Measured on +this lab, 45-flow core domain, PostgreSQL `max_connections = 100`, **distinct** callers (each has +their own vNext cache key, so none of them is a cache hit and single-flight collapses nothing): + +| Concurrent distinct callers | Before | After | +|---:|---|---| +| 20 | 13 → HTTP 500 (`53300 sorry, too many clients already`) | all 200 | +| 40 | 16 → HTTP 500 | all 200, peak 51 connections | +| 80 | — | all 200, peak 54 connections | + +What changed, in order of effect: + +- **vNext scans every flow in one statement on one connection.** All flows of a domain live in + schemas of the same database, so the per-flow scans differed only in the `FROM` clause; running + them as parallel branches bought a pooled connection each to do one sub-millisecond index-only + scan. `EXPLAIN` over all 44 arms: `Index Only Scan` per arm, `Heap Fetches: 0`, execution 0.92 ms. +- **vNext bounds descents process-wide** (`HumanTaskFunction:MaxConcurrentDescents`). + `FanoutParallelism` bounds one request; nothing bounded their product. +- **morph-idm's caps are in a different unit and cannot see this.** `GlobalMaxConcurrency` counts + morph-idm's outbound calls; the scarce resource is vNext-side connections, and the descent hops + vNext makes between domains are invisible to it entirely. Both sides need their own ceiling. + +## Gap worth closing (morph-idm side) + +vNext's row carries an additive `id` (the instance's own identifier) precisely because `instanceId` +is the business key and **is not unique** — a SubProcess child inherits its parent's key, so one case +can produce two rows with the same `instanceId`. `HumanTaskDto` does not map it, so the collision the +field exists to resolve is still invisible to the end client. Adding `Id` to the DTO and the +projection would close it; nothing else needs to change. diff --git a/api-tests/human-task-chain/build-human-task-chain.py b/api-tests/human-task-chain/build-human-task-chain.py new file mode 100644 index 0000000..ef3b2a2 --- /dev/null +++ b/api-tests/human-task-chain/build-human-task-chain.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Generate the `human-task-chain` scenario's six workflow components. + +The chain is A → B → C (core) → D (partner) → E → F (credit): six levels across three domains, so +one family exercises every shape the human-task leaf descent has to handle — several levels inside +one domain, a domain boundary, and a SECOND boundary crossed by a runtime other than the one the +client called. + +How deep a given instance goes is data, not definition. The start payload carries `hops`; every +descent decrements it and the `descend` rule fires only while it is positive. So: + + hops = 2 → leaf is C (Senaryo 1, one domain) + hops = 3 → leaf is D (Senaryo 2, one boundary) + hops = 5 → leaf is F (Senaryo 3, two boundaries) + hops = 0 → leaf is A (the root is its own leaf) + +Every level writes its OWN `humanTask.title`, which is what lets a test prove the listed title came +from the leaf rather than from the root — the defect this scenario exists for. + +Generated files are committed; re-run after editing this script, then run +`labs/cross-domain/encode-scripts.py` to fill each script's base64 `code` from its `.csx`. + +Usage: python3 api-tests/human-task-chain/build-human-task-chain.py +""" +import json +import os + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +VERSION = "1.0.6" +SCENARIO = "human-task-chain" + +# level key, domain, next level key (None at the leaf end of the definition chain) +CHAIN = [ + ("ht-a", "core", "ht-b"), + ("ht-b", "core", "ht-c"), + ("ht-c", "core", "ht-d"), + ("ht-d", "partner", "ht-e"), + ("ht-e", "credit", "ht-f"), + ("ht-f", "credit", None), +] + +DOMAIN_OF = {key: domain for key, domain, _ in CHAIN} + +DESCEND_RULE = '''using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Descend while `hops` is positive. Every subflow mapping decrements it, so the payload's initial +/// value alone decides which level ends up holding the human task — the definition chain is the +/// same for all three scenarios. +/// +public class DescendRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + var descend = hops > 0; + LogInformation($"DescendRule: hops={hops} descend={descend}"); + return Task.FromResult(descend); + } +} +''' + +SPAWN_RULE = '''using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Fires the root's SubProcess branch when the payload asks for it (mode = "process"). +/// A SubProcess is fire-and-forget: the root does not wait for it and nothing projects its state +/// upward, so the two become independent units of work and the list must carry BOTH. +/// +public class SpawnProcessRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var spawn = data != null + && data.TryGetValue("mode", out var mode) + && mode != null + && mode.ToString() == "process"; + + LogInformation($"SpawnProcessRule: spawn={spawn}"); + return Task.FromResult(spawn); + } +} +''' + +SPAWN_MAPPING = '''using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Input for the spawned SubProcess. It gets its OWN hop budget, because it is not a continuation +/// of the root's chain — it is a separate unit of work that may itself descend through SubFlows. +/// +/// +/// ISubProcessMapping, not ISubFlowMapping: a type: "P" subflow block is +/// resolved against this interface, and it declares only InputHandler — a SubProcess returns +/// nothing to its parent, which is fire-and-forget expressed in the contract. Implementing the +/// SubFlow interface instead faults the parent with Instance:100023. +/// +public class SpawnProcessMapping : ScriptBase, ISubProcessMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("processHops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-D process step"; + humanTask.description = "HT-D process step description"; + childInput.humanTask = humanTask; + + LogInformation($"SpawnProcessMapping: spawning ht-d as SubProcess with hops={hops}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } +} +''' + +SUBFLOW_MAPPING = '''using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class {cls} : ScriptBase, ISubFlowMapping +{{ + public Task InputHandler(ScriptContext context) + {{ + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + {{ + int.TryParse(raw.ToString(), out hops); + }} + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + {{ + childInput.testId = testId; + }} + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "{child} step"; + humanTask.description = "{child} step description"; + childInput.humanTask = humanTask; + + LogInformation($"{cls}: descending to {child} with hops={{hops - 1}}"); + return Task.FromResult(new ScriptResponse {{ Data = childInput }}); + }} + + public Task OutputHandler(ScriptContext context) + {{ + return Task.FromResult(new ScriptResponse()); + }} +}} +''' + + +def label(text): + return [{"language": "en-US", "label": text}] + + +def states(level, nxt, is_root=False): + """initial → (spawn process) | (descend → subflow → next level) | (human, the rest point).""" + initial_transitions = [] + + if is_root: + # Evaluated before descend, so `mode: "process"` wins over a hop budget. The root spawns a + # SubProcess and carries straight on to its own human state: nothing waits for the child, + # so the two are independent rows in the list. + initial_transitions.append({ + "key": f"{level}-spawn-process", + "target": f"{level}-spawn", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": label(f"{level}: spawn a SubProcess"), + "rule": {"location": "./src/SpawnProcessRule.csx", "code": ""}, + "onExecutionTasks": [] + }) + + if nxt is not None: + initial_transitions.append({ + "key": f"{level}-descend", + "target": f"{level}-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": label(f"{level}: descend to {nxt}"), + "rule": {"location": "./src/DescendRule.csx", "code": ""}, + "onExecutionTasks": [] + }) + + # The fallback. TransitionKind.DefaultAutoTransition (10) is the runtime's own answer to "run + # this when no other automatic transition is satisfied" — it is the ONLY automatic transition + # allowed to have no rule (WorkflowValidator.ValidateAutoTransition), and a state may declare at + # most one. Writing an inverted rule instead would duplicate the descend condition and let the + # two drift into a state with no way out. + initial_transitions.append({ + "key": f"{level}-to-human", + "target": f"{level}-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": label(f"{level}: rest in the human state"), + "onExecutionTasks": [] + }) + + result = [{ + "key": f"{level}-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": label(f"{level} initial"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": initial_transitions + }] + + if nxt is not None: + cls = f"{level.replace('-', '').capitalize()}ToNextSubFlowMapping" + result.append({ + "key": f"{level}-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": label(f"{level} subflow"), + "view": None, + "subFlow": { + "type": "S", + "process": { + "key": nxt, + "domain": DOMAIN_OF[nxt], + "version": VERSION, + "flow": "sys-flows" + }, + "mapping": {"location": f"./src/{cls}.csx", "code": ""}, + # Parent-defined overrides, stamped onto the child at start by SubflowStarter. + # + # `states` is the one the human-task list reads: it narrows the CHILD's visibility + # from the parent's point of view, and at a leaf it is the only way that narrowing + # can be honoured — the parent-side reader needs an active correlation, which a leaf + # by definition does not have. Keyed by the child's own state key. + # + # Authored only on the ht-d -> ht-e link. Two reasons: no other test rests on ht-e, + # so no existing scenario changes meaning; and that link crosses a domain boundary + # (partner -> credit), so the stamp has to survive a cross-domain start to be read + # at the leaf at all. + **({"overrides": { + "states": { + f"{nxt}-human": { + "queryRoles": [ + {"role": "xd-override-only", "grant": "allow"}, + {"role": "ht-blocked", "grant": "deny"}, + ] + } + }, + "transitions": { + f"{nxt}-approve": { + "roles": [{"role": "xd-override-only", "grant": "allow"}] + } + } + }} if nxt == "ht-e" else {}) + }, + "onEntries": [], + "onExits": [], + # The only way out of the SubFlow state, taken when the child comes back — a fallback + # by nature, so the same DefaultAutoTransition kind rather than an always-true rule. + "transitions": [{ + "key": f"{level}-subflow-done", + "target": f"{level}-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": label(f"{level}: subflow finished"), + "onExecutionTasks": [] + }] + }) + + if is_root: + result.append({ + "key": f"{level}-spawn", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": label(f"{level} spawn SubProcess"), + "view": None, + "subFlow": { + # type P — fire-and-forget. The parent gets no resume and no upward state + # projection, which is exactly why the child has to be listed on its own. + "type": "P", + "process": { + "key": "ht-d", + "domain": DOMAIN_OF["ht-d"], + "version": VERSION, + "flow": "sys-flows" + }, + "mapping": {"location": "./src/SpawnProcessMapping.csx", "code": ""} + }, + "onEntries": [], + "onExits": [], + "transitions": [{ + "key": f"{level}-spawned", + "target": f"{level}-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": label(f"{level}: carry on after spawning"), + "onExecutionTasks": [] + }] + }) + + # The human rest point. subType 6 is what puts this instance — or its root — in the human-task + # list, and the transition's roles are what the leaf-side authorization evaluates. + result.append({ + "key": f"{level}-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": label(f"{level} human task"), + # queryRoles is what the human-task list is decided by: it answers "may this caller SEE this + # instance", which is the question a task list asks. The transition roles below are still + # authored and still gate what the client is offered on open, but they no longer influence + # the list. Same three grants, so the level-discrimination and DENY tests keep their meaning. + "queryRoles": [ + {"role": "ht-approver", "grant": "allow"}, + {"role": f"{level}-approver", "grant": "allow"}, + {"role": "ht-blocked", "grant": "deny"}, + ], + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [{ + "key": f"{level}-approve", + "target": f"{level}-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": label(f"{level}: approve"), + # Three grants, each pinning a different rule of the evaluator: + # ht-approver - the broad role every existing scenario uses + # {level}-approver - ONLY this level. A caller holding just this one must see a chain + # whose leaf rests here and NOT one that rests elsewhere, which is + # what proves the decision came from the LEAF's transition set. + # ht-blocked - DENY, and DENY wins over the whole set however many ALLOWs match. + "roles": [ + {"role": "ht-approver", "grant": "allow"}, + {"role": f"{level}-approver", "grant": "allow"}, + {"role": "ht-blocked", "grant": "deny"}, + ], + "onExecutionTasks": [] + }] + }) + + result.append({ + "key": f"{level}-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": label(f"{level} completed"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [] + }) + + result.append({ + "key": f"{level}-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": label(f"{level} cancelled"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [] + }) + + return result + + +def component(level, domain, nxt, is_root): + return { + "key": level, + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": domain, + "version": VERSION, + "tags": [SCENARIO, "human-task", "subflow", domain], + "attributes": { + "type": "F" if is_root else "S", + "timeout": None, + "labels": label(f"Human Task Chain {level.upper()} ({domain})"), + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + # cancel carries a role deliberately. GetAvailableUserTransitionKeys appends the + # well-known cancel/updateData/exit from EVERY state, and an empty grant set allows + # everyone — so a role-less cancel would authorize every caller for every instance and + # make any "this caller must not see the task" assertion vacuous. + "cancel": { + "key": f"cancel-{level}", + "target": f"{level}-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label(f"Cancel {level}"), + # The SAME set as the level's approve transition, deliberately. cancel is appended + # to availableTransitions from every state, so a weaker grant set here would be a + # back door: a caller denied on approve would still be authorized through cancel and + # the task would appear anyway. + "roles": [ + {"role": "ht-approver", "grant": "allow"}, + {"role": f"{level}-approver", "grant": "allow"}, + {"role": "ht-blocked", "grant": "deny"}, + ] + }, + "startTransition": { + "key": f"start-{level}", + "target": f"{level}-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label(f"Start {level}"), + "onExecutionTasks": [] + }, + "states": states(level, nxt, is_root=is_root) + } + } + + +def write(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + print(f" {os.path.relpath(path, ROOT)}") + + +def main(): + print("human-task-chain components:") + for index, (level, domain, nxt) in enumerate(CHAIN): + folder = os.path.join(ROOT, domain, "Workflows", SCENARIO) + write( + os.path.join(folder, f"{level}.json"), + json.dumps(component(level, domain, nxt, is_root=index == 0), indent=2, ensure_ascii=False) + "\n") + + src = os.path.join(folder, "src") + write(os.path.join(src, "DescendRule.csx"), DESCEND_RULE) + if index == 0: + write(os.path.join(src, "SpawnProcessRule.csx"), SPAWN_RULE) + write(os.path.join(src, "SpawnProcessMapping.csx"), SPAWN_MAPPING) + if nxt is not None: + cls = f"{level.replace('-', '').capitalize()}ToNextSubFlowMapping" + write( + os.path.join(src, f"{cls}.csx"), + SUBFLOW_MAPPING.format(cls=cls, child=nxt.upper())) + + print("\nNow run: python3 labs/cross-domain/encode-scripts.py " + + " ".join(sorted({f'{d}/Workflows/{SCENARIO}' for _, d, _ in CHAIN}))) + + +if __name__ == "__main__": + main() diff --git a/api-tests/human-task-chain/morph-idm-aggregation-test.py b/api-tests/human-task-chain/morph-idm-aggregation-test.py new file mode 100755 index 0000000..ca49806 --- /dev/null +++ b/api-tests/human-task-chain/morph-idm-aggregation-test.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""End-to-end check of the human-task path through morph-idm-api. + +The integration suite in tests/Core.IntegrationTests proves one domain's answer. This proves the +CLIENT's answer: morph-idm reads discovery's domain-list, calls every domain's human-task function, +and merges. It is the only place the whole chain is exercised together — + + client -> morph-idm -> discovery domain-list -> per-domain human-task -> leaf descent -> leaf + +Requires the four-domain lab and morph-idm on the same Docker network (see README.md). + + python3 api-tests/human-task-chain/morph-idm-aggregation-test.py \ + --core http://localhost:4201 --idm http://localhost:5288 + +Exit code 0 when every check passes, 1 otherwise. No dependencies beyond the standard library. +""" +import argparse +import json +import sys +import time +import subprocess +import urllib.error +import urllib.request +from collections import Counter + +ROLE = "ht-approver" +USER = "aggregation-test" + +# hops -> the leaf that ends up holding the task. The chain is a -> b -> c (core) -> d (partner) +# -> e -> f (credit); every level writes its own humanTask text, so the title names the leaf. +SCENARIOS = [ + (0, "HT-A step", "root is its own leaf"), + (2, "HT-C step", "three levels, one domain"), + (3, "HT-D step", "one domain boundary"), + (5, "HT-F step", "two boundaries, second crossed by partner"), +] + +failures: list[str] = [] +checks = 0 + + +def check(condition: bool, label: str, detail: str = "") -> bool: + global checks + checks += 1 + if condition: + print(f" PASS {label}") + return True + failures.append(f"{label}{(' — ' + detail) if detail else ''}") + print(f" FAIL {label}{(' — ' + detail) if detail else ''}") + return False + + +def request(url: str, headers: dict[str, str] | None = None, body: dict | None = None, + method: str | None = None, timeout: int = 30, with_headers: bool = False): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method or ("POST" if data else "GET")) + for key, value in (headers or {}).items(): + req.add_header(key, value) + if data: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode() + payload = json.loads(raw) if raw.strip() else None + # with_headers swaps the status for the response headers: the truncation signal travels + # beside the body, so a check on it needs both halves. + return (dict(response.headers), payload) if with_headers else (response.status, payload) + + +def vnext_headers(role: str = ROLE, fresh: bool = False) -> dict[str, str]: + headers = { + "x-roles": role, + "role": role, + "user_reference": USER, + "x-device-id": "aggregation-test", + } + if fresh: + # The response cache has a 60 s TTL and no validation query. A test that polls through it + # waits out the TTL on a pre-change answer, then reports a wrong list rather than a stale + # one — which is a different and far more misleading failure. + headers["X-VNext-Cache-Override"] = "true" + return headers + + +def start_chain(core: str, hops: int) -> str: + _, body = request( + f"{core}/api/v1/core/workflows/ht-a/instances/start?sync=true", + headers=vnext_headers(), + body={ + "hops": hops, + "testId": f"aggregation-{hops}-{int(time.time())}", + "humanTask": {"title": "HT-A step", "description": "HT-A step description"}, + }, + ) + return body["id"] + + +def start_spawn(core: str, process_hops: int) -> str: + """ + Starts a root that spawns a SubProcess (`ht-a-spawn`, subFlow.type = "P") into partner and + itself rests in its own human state. + + Started ASYNC on purpose. A state-level SubProcess followed by an automatic transition cannot be + started with `sync=true` today: the post-commit ContinueParent continuation re-enters the + pipeline with IsPreReserved = false while the instance is still Busy from the stage that + continuation belongs to, so admission answers 409 conflict.Instance:100031. See + TEST-SCENARIOS.md § Bilinen Kapsam Açıkları. + """ + _, body = request( + f"{core}/api/v1/core/workflows/ht-a/instances/start", + headers=vnext_headers(), + body={ + "mode": "process", + "hops": 0, + "processHops": process_hops, + "testId": f"aggregation-spawn-{int(time.time())}", + "humanTask": {"title": "HT-A step", "description": "HT-A step description"}, + }, + ) + return body["id"] + + +def wait_for(predicate, describe: str, timeout: int = 120): + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(2) + print(f" FAIL timed out after {timeout}s waiting for {describe}") + failures.append(f"timeout: {describe}") + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--core", default="http://localhost:4201") + parser.add_argument("--partner", default="http://localhost:4211") + parser.add_argument("--credit", default="http://localhost:4221") + parser.add_argument("--discovery", default="http://localhost:4231") + parser.add_argument("--idm", default="http://localhost:5288") + parser.add_argument("--timeout", type=int, default=120, help="seconds to wait for a chain to settle") + args = parser.parse_args() + + print("\n[1] discovery domain-list — the list morph-idm fans out over") + try: + _, listing = request(f"{args.discovery}/api/v1/discovery/functions/domain-list") + except urllib.error.URLError as error: + print(f" FAIL discovery unreachable: {error}") + return 1 + + domains = {d["domainName"]: d for d in (listing or {}).get("items", [])} + print(f" {len(domains)} domain(s): {', '.join(sorted(domains))}") + check("core" in domains, "core is registered") + for name in ("partner", "credit"): + # Not fatal for the aggregation — core answers for the whole chain — but their absence + # means the descent's remote hops would fail, so it is worth naming here. + check(name in domains, f"{name} is registered") + + print("\n[2] start one chain per scenario") + started: dict[int, str] = {} + for hops, title, why in SCENARIOS: + try: + started[hops] = start_chain(args.core, hops) + print(f" hops={hops} ({why}) -> {started[hops][:8]} expecting '{title}'") + except urllib.error.HTTPError as error: + print(f" FAIL start(hops={hops}) failed: {error.read().decode()[:200]}") + failures.append(f"start hops={hops}") + + spawn_root = None + try: + spawn_root = start_spawn(args.core, process_hops=2) + print(f" spawn (SubProcess into partner, 2 more levels) -> {spawn_root[:8]}") + except urllib.error.HTTPError as error: + print(f" FAIL spawn start failed: {error.read().decode()[:200]}") + failures.append("start spawn") + + def core_rows(role: str = ROLE, fresh: bool = True): + _, rows = request(f"{args.core}/api/v1/core/functions/human-task", + headers=vnext_headers(role, fresh=fresh)) + return rows or [] + + print("\n[3] wait until every chain has come to rest on its leaf") + wait_for(lambda: {r["id"] for r in core_rows()} >= set(started.values()), + "all started chains to surface", args.timeout) + + print("\n[4] the leaf's text reaches core's own list") + rows_by_id = {r["id"]: r for r in core_rows()} + for hops, title, _ in SCENARIOS: + row = rows_by_id.get(started.get(hops, "")) + check(row is not None and row.get("title") == title, + f"hops={hops} -> '{title}'", + f"got {row.get('title')!r}" if row else "row missing") + if row: + check(row.get("workflow") == "ht-a", + f"hops={hops} row is addressed by the ROOT", + f"workflow={row.get('workflow')!r}") + + print("\n[5] a SubFlow child is represented by its root; a SubProcess is not") + # An `S` child is the SAME unit of work as its root, so the domain that hosts it must not list + # it a second time — the root already stands for it. A `P` child is an INDEPENDENT flow: its + # domain lists it on its own, under its own id, and it descends into its own SubFlows. + child_rows: dict[str, list] = {} + for name, base in (("partner", args.partner), ("credit", args.credit)): + try: + _, rows = request(f"{base}/api/v1/{name}/functions/human-task", + headers=vnext_headers(fresh=True)) + child_rows[name] = rows or [] + except urllib.error.URLError as error: + print(f" SKIP {name} unreachable ({error})") + + started_ids = set(started.values()) | ({spawn_root} if spawn_root else set()) + for name, rows in child_rows.items(): + leaked = [r for r in rows if r.get("id") in started_ids] + check(not leaked, f"{name} does not list a root of this run", + f"{len(leaked)} row(s) — a SubFlow child must be represented by its root, not listed twice") + check(all(r.get("workflow") != "ht-a" for r in rows), + f"{name} lists no ht-a row of its own") + + if spawn_root: + spawned = [r for r in child_rows.get("partner", []) if r.get("workflow") == "ht-d"] + check(bool(spawned), "partner lists the spawned SubProcess on its own", + "no ht-d row in partner's answer") + # A SubProcess inherits its parent's business Key, so addressing it by Key would send a + # client to the ROOT. It must be addressed by its own id. + check(all(r.get("instanceId") == r.get("id") for r in spawned), + "the SubProcess is addressed by its own id, not its parent's key", + f"{[(r.get('instanceId'), r.get('id')) for r in spawned][:3]}") + check(any(r.get("title") == "HT-F step" for r in spawned), + "the SubProcess descended its own SubFlows into credit", + f"titles: {sorted({r.get('title') for r in spawned})}") + + print("\n[6] a caller with another role sees none of it") + other = {r["id"] for r in core_rows(role="some.other.role")} + check(not (other & set(started.values())), + "leaf-side authorization excludes the wrong role") + + print("\n[7] morph-idm aggregation — the client's answer") + try: + _, payload = request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": USER, "role": ROLE}) + except urllib.error.URLError as error: + print(f" FAIL morph-idm unreachable: {error}") + failures.append("morph-idm unreachable") + payload = None + + if payload is not None: + tasks = payload.get("humanTasks", []) + titles = Counter(t.get("title") for t in tasks) + print(f" {len(tasks)} task(s); titles: {dict(titles)}") + print(f" domains: {dict(Counter(t.get('domainName') for t in tasks))}") + + for _, title, why in SCENARIOS: + check(titles.get(title, 0) > 0, f"aggregation carries '{title}' ({why})") + + check(all(t.get("domainName") for t in tasks), + "every row names the domain that answered") + check(all(t.get("vnextTask") for t in tasks), + "every row is flagged as a vNext task") + + print("\n[8] morph-idm carries the caller's context down and the domains' truncation up") + try: + # Cache override: without it a second read of the SAME caller is served from vNext's 60 s + # cache; with it every read rebuilds. Timing is the only observable difference, so the check + # is a ratio rather than an absolute. + import time as _t + user_cached = f"ctx-cached-{int(_t.time())}" + _, _ = request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": user_cached, "role": ROLE}) + t0 = _t.time() + request(f"{args.idm}/api/discovery/human-tasks", headers={"act_sub": user_cached, "role": ROLE}) + cached_ms = (_t.time() - t0) * 1000 + + user_fresh = f"ctx-fresh-{int(_t.time())}" + request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": user_fresh, "role": ROLE, "X-VNext-Cache-Override": "true"}) + t0 = _t.time() + request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": user_fresh, "role": ROLE, "X-VNext-Cache-Override": "true"}) + fresh_ms = (_t.time() - t0) * 1000 + + print(f" repeat read: cached {cached_ms:.0f} ms vs override {fresh_ms:.0f} ms") + check(fresh_ms > cached_ms * 1.5, + "X-VNext-Cache-Override reaches the domains (a repeat read rebuilds)", + f"cached={cached_ms:.0f}ms override={fresh_ms:.0f}ms - the header looks dropped") + + # Correlation: the id must reach the domain runtime, which is what makes one client request + # followable across morph-idm and every domain it fanned out to. + marker = f"e2e-corr-{int(_t.time())}" + request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": USER, "role": ROLE, "x-request-id": marker}) + _t.sleep(2) + found = subprocess.run( + ["docker", "logs", "vnext-app-core", "--since", "60s"], + capture_output=True, text=True).stdout.count(marker) + check(found > 0, "x-request-id reaches the domain runtime", + f"marker {marker} not found in vnext-app-core logs") + + # Truncation: vNext answers X-VNext-HumanTask-Truncated per domain; the aggregate has to say + # so too, or a cut list is indistinguishable from a complete one. + headers, payload = request(f"{args.idm}/api/discovery/human-tasks", + headers={"act_sub": USER, "role": ROLE}, with_headers=True) + body_flag = payload.get("truncated") + header_flag = str(headers.get("X-VNext-HumanTask-Truncated", "")).lower() == "true" + print(f" truncated={body_flag} domains={payload.get('truncatedDomains')} header={header_flag}") + check(body_flag is not None, "the aggregate reports its truncation state") + check(body_flag == header_flag, + "body and response header agree on truncation", + f"body={body_flag} header={header_flag}") + if body_flag: + check(bool(payload.get("truncatedDomains")), + "a truncated aggregate names the domains that cut their list") + except urllib.error.URLError as error: + print(f" FAIL context round trip failed: {error}") + failures.append("context round trip") + + print("\n" + "=" * 70) + if failures: + print(f"FAILED — {len(failures)} of {checks} checks") + for failure in failures: + print(f" - {failure}") + return 1 + print(f"PASSED — {checks} checks") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api-tests/human-task-chain/morph-idm-aggregation.http b/api-tests/human-task-chain/morph-idm-aggregation.http new file mode 100644 index 0000000..ae1798f --- /dev/null +++ b/api-tests/human-task-chain/morph-idm-aggregation.http @@ -0,0 +1,162 @@ +### morph-idm-api human-task aggregation — hand-driven walkthrough +### +### Runs the whole client path one hop at a time, so a failure can be attributed to a layer +### instead of to "the list is empty". Prerequisites and the container command are in README.md. +### +### Ports: core :4201 · partner :4211 · credit :4221 · discovery :4231 · morph-idm :5288 + +@core = http://localhost:4201 +@partner = http://localhost:4211 +@credit = http://localhost:4221 +@discovery = http://localhost:4231 +@idm = http://localhost:5288 +@role = ht-approver +@user = integration-test-user + + +### 1. Discovery knows the domains +# morph-idm fans out over exactly this list. The baseUrls are CONTAINER-INTERNAL +# (http://vnext-app-core:5000), which is why morph-idm has to run on the lab network — see README. +GET {{discovery}}/api/v1/discovery/functions/domain-list + + +### 2. The same domain, resolved singly +# domain-lookup and domain-list read different caches. When they disagree, the bulk key +# (vnext||custom:discovery:domains:active in Redis) is the stale one. +GET {{discovery}}/api/v1/discovery/functions/domain-lookup?key=core + + +### 3. core's own human-task list — the row the client will see +# One row per waiting ROOT. The title comes from the LEAF, however deep it is. +GET {{core}}/api/v1/core/functions/human-task +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + + +### 4. The same list, bypassing the 60 s response cache +# Use this after starting or completing an instance; otherwise the cached answer is served and +# the change looks like it never happened. +GET {{core}}/api/v1/core/functions/human-task +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file +X-VNext-Cache-Override: true + + +### 5. partner's list — no root of the chain, but every spawned SubProcess +# The chain's partner level is a SubFlow child (Type=S): the SAME unit of work as core's root, so +# it is represented there and must NOT appear again here. A `ht-a` row or one of core's root ids +# showing up means the selection predicate started listing S children. +# A SubProcess (Type=P, step 11) is the opposite case: an independent flow that partner lists on +# its own — workflow `ht-d`, `instanceId` equal to its `id` (never its parent's inherited Key), +# and the title of the leaf it descended to on its own (`HT-F step` with processHops=2). +GET {{partner}}/api/v1/partner/functions/human-task +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + + +### 6. credit's list — expected EMPTY, for the same reason +GET {{credit}}/api/v1/credit/functions/human-task +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + + +### 7. The aggregation — what a client actually calls +# Each row carries domainName, so a task is traceable back to the domain that answered. +# The body also carries `truncated` and `truncatedDomains`: vNext bounds each domain's answer +# (PerSchemaLimit per flow, ResultCap per response) and says so with X-VNext-HumanTask-Truncated; +# morph-idm merges those into one flag and echoes the same header on its own response. Without it a +# cut list is indistinguishable from a complete one - it is simply shorter. +GET {{idm}}/api/discovery/human-tasks +act_sub: {{user}} +role: {{role}} + + +### 8. Start a chain that rests two levels down (leaf = ht-c) +POST {{core}}/api/v1/core/workflows/ht-a/instances/start?sync=true +Content-Type: application/json +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + +{ + "hops": 2, + "testId": "http-file-s1", + "humanTask": { "title": "HT-A step", "description": "HT-A step description" } +} + + +### 9. Start a chain that crosses BOTH boundaries (leaf = ht-f, in credit) +POST {{core}}/api/v1/core/workflows/ht-a/instances/start?sync=true +Content-Type: application/json +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + +{ + "hops": 5, + "testId": "http-file-s3", + "humanTask": { "title": "HT-A step", "description": "HT-A step description" } +} + + +### 11. Start a root that SPAWNS a SubProcess into partner (async — see note) +# `mode: "process"` arms the ht-a-spawn state (subFlow.type = "P") and processHops sends the +# SubProcess two further SubFlow levels, into credit. The root itself rests in ht-a-human, so this +# one start produces TWO rows in two different domains' answers: core's root ("HT-A step") and +# partner's SubProcess ("HT-F step"). +# +# NOTE: no `?sync=true`. A state-level SubProcess followed by an automatic transition cannot be +# started synchronously today — the post-commit ContinueParent continuation re-enters the pipeline +# with IsPreReserved = false while the instance is still Busy from the stage it belongs to, so +# admission answers 409 conflict.Instance:100031. See TEST-SCENARIOS.md § Bilinen Kapsam Açıkları. +POST {{core}}/api/v1/core/workflows/ht-a/instances/start +Content-Type: application/json +x-roles: {{role}} +role: {{role}} +user_reference: {{user}} +x-device-id: http-file + +{ + "mode": "process", + "hops": 0, + "processHops": 2, + "testId": "http-file-spawn", + "humanTask": { "title": "HT-A step", "description": "HT-A step description" } +} + + +### 12. The caller's context travelling down, and the domains' truncation coming back +# Cache override: vNext's response cache is 60 s with no validation query, so a client that just +# completed a task would otherwise read the pre-change list for a full TTL. Send this twice with the +# SAME act_sub and compare durations against step 7 - with the header every read rebuilds. +# Correlation: traceparent / x-request-id are forwarded verbatim into every domain, so one client +# request can be followed across morph-idm and the whole fan-out. Every forwarded name is on vNext's +# volatile-header denylist, which is what keeps them OUT of its cache key - a per-request value that +# entered the key would turn every call into a miss. +GET {{idm}}/api/discovery/human-tasks +act_sub: {{user}} +role: {{role}} +X-VNext-Cache-Override: true +traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 +x-request-id: http-file-correlation-1 + + +### 10. A caller holding a different role — expected to see none of the chain rows +# The leaf's approve transition grants only ht-approver, and cancel is role-gated too, so this +# proves the filter ran against the LEAF's transition set. +GET {{core}}/api/v1/core/functions/human-task +x-roles: some.other.role +role: some.other.role +user_reference: {{user}} +x-device-id: http-file +X-VNext-Cache-Override: true diff --git a/core/Functions/task-invocation-lab/src/TilCachedEchoMapping.csx b/core/Functions/task-invocation-lab/src/TilCachedEchoMapping.csx new file mode 100644 index 0000000..cbecaf9 --- /dev/null +++ b/core/Functions/task-invocation-lab/src/TilCachedEchoMapping.csx @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Definitions; +using BBT.Workflow.Scripting; + +/// +/// til-cached-echo — the probe for the FUNCTION RESPONSE CACHE path +/// (IStateStoreCacheGateway → ITaskInvocationDispatcher), which issue #1007 moved onto +/// the same routing seam as the five wire task types. No other scenario in this repository configures +/// function.cache, so without this function that gateway is never exercised end to end and the +/// Cache.Get/Cache.Set spans it emits (component type function-response) never +/// appear in a trace. +/// +/// Deliberately trivial: it wraps the til-http-ok MockLab call so a cache MISS costs one +/// outbound HTTP round trip and a HIT costs none. The measurable difference between the two is the +/// whole point — the function's own body must not add noise. +/// +/// +public class TilCachedEchoMapping : ScriptBase, IMapping +{ + public Task InputHandler(WorkflowTask task, ScriptContext context) + { + // The task definition ships the API_BASEURL placeholder and relies on its INPUT HANDLER to + // resolve it. A function supplies its own mapping, which replaces the task's — so without + // this line the URL stays relative and the call dies with "request URI must be absolute". + if (task is HttpTask httpTask) + { + var apiBaseUrl = GetConfigValue("Example:ApiBaseUrl", "http://localhost:3001"); + httpTask.SetUrl(httpTask.Url.Replace("API_BASEURL", apiBaseUrl)); + } + + return Task.FromResult(new ScriptResponse()); + } + + public Task OutputHandler(ScriptContext context) + { + dynamic payload = new ExpandoObject(); + var target = (IDictionary)payload; + + // A value that changes per execution: when the cache serves the response, this stays + // frozen at the value computed on the miss — which is how a test tells a hit from a miss + // without reading spans. + target["computedAtUtc"] = DateTime.UtcNow.ToString("O"); + target["source"] = "til-cached-echo"; + + dynamic response = new ExpandoObject(); + ((IDictionary)response)["data"] = payload; + + LogInformation("TilCachedEchoMapping: response computed (this line does NOT run on a cache hit)"); + return Task.FromResult(new ScriptResponse { Data = response }); + } +} diff --git a/core/Functions/task-invocation-lab/til-cached-echo.json b/core/Functions/task-invocation-lab/til-cached-echo.json new file mode 100644 index 0000000..531614f --- /dev/null +++ b/core/Functions/task-invocation-lab/til-cached-echo.json @@ -0,0 +1,45 @@ +{ + "key": "til-cached-echo", + "version": "1.0.1", + "domain": "core", + "flow": "sys-functions", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "function", + "cache" + ], + "attributes": { + "scope": "D", + "rawResponse": false, + "labels": [ + { + "language": "en-US", + "label": "Task Invocation Lab — Cached Echo" + }, + { + "language": "tr-TR", + "label": "Task Invocation Lab — Onbellekli Echo" + } + ], + "cache": { + "key": "til:fncache:echo", + "ttlInSeconds": 60, + "bypassOnCacheError": true + }, + "task": { + "order": 1, + "task": { + "key": "til-http-ok", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilCachedEchoMapping.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIHRpbC1jYWNoZWQtZWNobyDigJQgdGhlIHByb2JlIGZvciB0aGUgRlVOQ1RJT04gUkVTUE9OU0UgQ0FDSEUgcGF0aAovLy8gKDxjPklTdGF0ZVN0b3JlQ2FjaGVHYXRld2F5PC9jPiDihpIgPGM+SVRhc2tJbnZvY2F0aW9uRGlzcGF0Y2hlcjwvYz4pLCB3aGljaCBpc3N1ZSAjMTAwNyBtb3ZlZCBvbnRvCi8vLyB0aGUgc2FtZSByb3V0aW5nIHNlYW0gYXMgdGhlIGZpdmUgd2lyZSB0YXNrIHR5cGVzLiBObyBvdGhlciBzY2VuYXJpbyBpbiB0aGlzIHJlcG9zaXRvcnkgY29uZmlndXJlcwovLy8gPGM+ZnVuY3Rpb24uY2FjaGU8L2M+LCBzbyB3aXRob3V0IHRoaXMgZnVuY3Rpb24gdGhhdCBnYXRld2F5IGlzIG5ldmVyIGV4ZXJjaXNlZCBlbmQgdG8gZW5kIGFuZCB0aGUKLy8vIDxjPkNhY2hlLkdldDwvYz4vPGM+Q2FjaGUuU2V0PC9jPiBzcGFucyBpdCBlbWl0cyAoY29tcG9uZW50IHR5cGUgPGM+ZnVuY3Rpb24tcmVzcG9uc2U8L2M+KSBuZXZlcgovLy8gYXBwZWFyIGluIGEgdHJhY2UuCi8vLyA8cGFyYT4KLy8vIERlbGliZXJhdGVseSB0cml2aWFsOiBpdCB3cmFwcyB0aGUgPGM+dGlsLWh0dHAtb2s8L2M+IE1vY2tMYWIgY2FsbCBzbyBhIGNhY2hlIE1JU1MgY29zdHMgb25lCi8vLyBvdXRib3VuZCBIVFRQIHJvdW5kIHRyaXAgYW5kIGEgSElUIGNvc3RzIG5vbmUuIFRoZSBtZWFzdXJhYmxlIGRpZmZlcmVuY2UgYmV0d2VlbiB0aGUgdHdvIGlzIHRoZQovLy8gd2hvbGUgcG9pbnQg4oCUIHRoZSBmdW5jdGlvbidzIG93biBib2R5IG11c3Qgbm90IGFkZCBub2lzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbENhY2hlZEVjaG9NYXBwaW5nIDogU2NyaXB0QmFzZSwgSU1hcHBpbmcKewogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IElucHV0SGFuZGxlcihXb3JrZmxvd1Rhc2sgdGFzaywgU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIC8vIFRoZSB0YXNrIGRlZmluaXRpb24gc2hpcHMgdGhlIEFQSV9CQVNFVVJMIHBsYWNlaG9sZGVyIGFuZCByZWxpZXMgb24gaXRzIElOUFVUIEhBTkRMRVIgdG8KICAgICAgICAvLyByZXNvbHZlIGl0LiBBIGZ1bmN0aW9uIHN1cHBsaWVzIGl0cyBvd24gbWFwcGluZywgd2hpY2ggcmVwbGFjZXMgdGhlIHRhc2sncyDigJQgc28gd2l0aG91dAogICAgICAgIC8vIHRoaXMgbGluZSB0aGUgVVJMIHN0YXlzIHJlbGF0aXZlIGFuZCB0aGUgY2FsbCBkaWVzIHdpdGggInJlcXVlc3QgVVJJIG11c3QgYmUgYWJzb2x1dGUiLgogICAgICAgIGlmICh0YXNrIGlzIEh0dHBUYXNrIGh0dHBUYXNrKQogICAgICAgIHsKICAgICAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwogICAgICAgICAgICBodHRwVGFzay5TZXRVcmwoaHR0cFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIGR5bmFtaWMgcGF5bG9hZCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgdmFyIHRhcmdldCA9IChJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4pcGF5bG9hZDsKCiAgICAgICAgLy8gQSB2YWx1ZSB0aGF0IGNoYW5nZXMgcGVyIGV4ZWN1dGlvbjogd2hlbiB0aGUgY2FjaGUgc2VydmVzIHRoZSByZXNwb25zZSwgdGhpcyBzdGF5cwogICAgICAgIC8vIGZyb3plbiBhdCB0aGUgdmFsdWUgY29tcHV0ZWQgb24gdGhlIG1pc3Mg4oCUIHdoaWNoIGlzIGhvdyBhIHRlc3QgdGVsbHMgYSBoaXQgZnJvbSBhIG1pc3MKICAgICAgICAvLyB3aXRob3V0IHJlYWRpbmcgc3BhbnMuCiAgICAgICAgdGFyZ2V0WyJjb21wdXRlZEF0VXRjIl0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIk8iKTsKICAgICAgICB0YXJnZXRbInNvdXJjZSJdID0gInRpbC1jYWNoZWQtZWNobyI7CgogICAgICAgIGR5bmFtaWMgcmVzcG9uc2UgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgICgoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3BvbnNlKVsiZGF0YSJdID0gcGF5bG9hZDsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oIlRpbENhY2hlZEVjaG9NYXBwaW5nOiByZXNwb25zZSBjb21wdXRlZCAodGhpcyBsaW5lIGRvZXMgTk9UIHJ1biBvbiBhIGNhY2hlIGhpdCkiKTsKICAgICAgICByZXR1cm4gVGFzay5Gcm9tUmVzdWx0KG5ldyBTY3JpcHRSZXNwb25zZSB7IERhdGEgPSByZXNwb25zZSB9KTsKICAgIH0KfQo=" + } + } + } +} \ No newline at end of file diff --git a/core/Mappings/script-perf-lab/perf-chunk-helper.json b/core/Mappings/script-perf-lab/perf-chunk-helper.json index ff63614..a317c49 100644 --- a/core/Mappings/script-perf-lab/perf-chunk-helper.json +++ b/core/Mappings/script-perf-lab/perf-chunk-helper.json @@ -12,7 +12,7 @@ "attributes": { "name": "PerfChunkHelper", "location": "./src/PerfChunkHelper.csx", - "code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Perf.Helpers;\n\n/// \n/// script-perf-lab: deterministik, istenen boyutta chunk \u00fcretir. Ama\u00e7 instance dok\u00fcman\u0131n\u0131\n/// stage ba\u015f\u0131na parametrik b\u00fcy\u00fctmek (B9 append profili). StringBuilder bilin\u00e7li yok \u2014\n/// script derlemesinde System.Text using'i mevcut de\u011fil.\n/// \n/// Kas\u0131tl\u0131 olarak D\u00dc\u011e\u00dcM-ZENG\u0130N bir \u015fekil d\u00f6ner (kb adet ~1KB node'luk liste), tek bir b\u00fcy\u00fck\n/// string DE\u011e\u0130L: tek string dok\u00fcman geni\u015fli\u011fini b\u00fcy\u00fct\u00fcr ama JSON d\u00fc\u011f\u00fcm say\u0131s\u0131n\u0131 ~21'de sabit\n/// tutard\u0131, bu da B9'un as\u0131l \u00f6l\u00e7mek istedi\u011fi per-node maliyetini (NormalizedJson / per-object\n/// SerializeToElement) hi\u00e7 tetiklemezdi.\n/// \n/// \npublic static class PerfChunkHelper\n{\n public static List Build(int stage, int kb)\n {\n var unit = \"s\" + stage + \"-0123456789abcdefghijklmnopqrstuvwxyz-\";\n var segment = string.Concat(Enumerable.Repeat(unit, 1024 / unit.Length + 1)).Substring(0, 1024);\n var segments = new List();\n for (var i = 0; i < Math.Max(1, kb); i++)\n {\n segments.Add(new { i, stage, seg = segment });\n }\n return segments;\n }\n}\n", + "code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Perf.Helpers;\n\n/// \n/// script-perf-lab: deterministik, istenen boyutta chunk üretir. Amaç instance dokümanını\n/// stage başına parametrik büyütmek (B9 append profili). StringBuilder bilinçli yok —\n/// script derlemesinde System.Text using'i mevcut değil.\n/// \n/// Kasıtlı olarak DÜĞÜM-ZENGİN bir şekil döner (kb adet ~1KB node'luk liste), tek bir büyük\n/// string DEĞİL: tek string doküman genişliğini büyütür ama JSON düğüm sayısını ~21'de sabit\n/// tutardı, bu da B9'un asıl ölçmek istediği per-node maliyetini (NormalizedJson / per-object\n/// SerializeToElement) hiç tetiklemezdi.\n/// \n/// \npublic static class PerfChunkHelper\n{\n public static List Build(int stage, int kb)\n {\n var unit = \"s\" + stage + \"-0123456789abcdefghijklmnopqrstuvwxyz-\";\n var segment = string.Concat(Enumerable.Repeat(unit, 1024 / unit.Length + 1)).Substring(0, 1024);\n var segments = new List();\n for (var i = 0; i < Math.Max(1, kb); i++)\n {\n segments.Add(new { i, stage, seg = segment });\n }\n return segments;\n }\n}\n", "encoding": "NAT" } -} +} \ No newline at end of file diff --git a/core/Mappings/script-perf-lab/perf-stamp-helper.json b/core/Mappings/script-perf-lab/perf-stamp-helper.json index 1395e96..358caa4 100644 --- a/core/Mappings/script-perf-lab/perf-stamp-helper.json +++ b/core/Mappings/script-perf-lab/perf-stamp-helper.json @@ -12,7 +12,7 @@ "attributes": { "name": "PerfStampHelper", "location": "./src/PerfStampHelper.csx", - "code": "using System;\n\nnamespace Perf.Helpers;\n\n/// \u0130kinci helper \u2014 helper-set'in \u00e7ok \u00fcyeli (A7) yolunu tetiklemek i\u00e7in var.\npublic static class PerfStampHelper\n{\n public static string Stage(int stage, string instanceId) =>\n \"perf:\" + stage + \":\" + (instanceId ?? \"none\");\n}\n", + "code": "using System;\n\nnamespace Perf.Helpers;\n\n/// İkinci helper — helper-set'in çok üyeli (A7) yolunu tetiklemek için var.\npublic static class PerfStampHelper\n{\n public static string Stage(int stage, string instanceId) =>\n \"perf:\" + stage + \":\" + (instanceId ?? \"none\");\n}\n", "encoding": "NAT" } -} +} \ No newline at end of file diff --git a/core/Mappings/script-race-lab/race-helper.json b/core/Mappings/script-race-lab/race-helper.json index 9f06baa..4bef7d7 100644 --- a/core/Mappings/script-race-lab/race-helper.json +++ b/core/Mappings/script-race-lab/race-helper.json @@ -11,7 +11,7 @@ "attributes": { "name": "RaceHelper", "location": "./src/RaceHelper.csx", - "code": "using System;\n\nnamespace Acme.Helpers;\n\n/// \n/// Global helper for the script-race-lab fixture.\n/// \n/// Its body is irrelevant; its existence is the point. A workflow that declares\n/// scripts.helpers makes every script it compiles share the helper set's\n/// singleton-lifetime AssemblyLoadContext, and a shared context cannot hold two assemblies with\n/// the same simple name \u2014 which is the collision the fixture reproduces.\n/// \n/// \npublic static class RaceHelper\n{\n /// Deterministic stamp, so a test can assert the helper really resolved.\n public static string Stamp(string testId) => \"race:\" + (testId ?? \"none\");\n}\n", + "code": "using System;\n\nnamespace Acme.Helpers;\n\n/// \n/// Global helper for the script-race-lab fixture.\n/// \n/// Its body is irrelevant; its existence is the point. A workflow that declares\n/// scripts.helpers makes every script it compiles share the helper set's\n/// singleton-lifetime AssemblyLoadContext, and a shared context cannot hold two assemblies with\n/// the same simple name — which is the collision the fixture reproduces.\n/// \n/// \npublic static class RaceHelper\n{\n /// Deterministic stamp, so a test can assert the helper really resolved.\n public static string Stamp(string testId) => \"race:\" + (testId ?? \"none\");\n}\n", "encoding": "NAT" } -} +} \ No newline at end of file diff --git a/core/Schemas/subflow-start-failure-lab/subflow-start-failure-lab-child-start.json b/core/Schemas/subflow-start-failure-lab/subflow-start-failure-lab-child-start.json new file mode 100644 index 0000000..dd17c1c --- /dev/null +++ b/core/Schemas/subflow-start-failure-lab/subflow-start-failure-lab-child-start.json @@ -0,0 +1,32 @@ +{ + "key": "subflow-start-failure-lab-child-start", + "version": "1.0.0", + "domain": "core", + "flow": "sys-schemas", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "subflow-start-failure-lab", + "input-schema" + ], + "attributes": { + "type": "workflow", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:vnext:res:schema:core:subflow-start-failure-lab-child-start", + "title": "SubFlow Start Failure Lab Child Start", + "description": "Deliberately requires a field the parent's ParentToChildSubFlowMapping never supplies, so the child's start transition fails schema validation post-commit, inside StartSubflowJobHandler/SubflowStarter — after HandleSubFlowStep has already committed the parent's InstanceCorrelation.", + "type": "object", + "required": ["mustProvide"], + "properties": { + "mustProvide": { + "type": "string", + "minLength": 1, + "title": "Must Provide", + "x-labels": { "en": "Must Provide", "tr": "Zorunlu Alan" } + } + }, + "additionalProperties": true + } + } +} diff --git a/core/Tasks/human-task-chain/ht-a-spawn-process.json b/core/Tasks/human-task-chain/ht-a-spawn-process.json new file mode 100644 index 0000000..2ad8204 --- /dev/null +++ b/core/Tasks/human-task-chain/ht-a-spawn-process.json @@ -0,0 +1,18 @@ +{ + "key": "ht-a-spawn-process", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": ["human-task-chain", "subprocess", "dapr"], + "attributes": { + "type": "14", + "config": { + "domain": "partner", + "flow": "ht-d", + "version": "1.0.6", + "useDapr": true, + "timeoutSeconds": 30 + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-cache-source.json b/core/Tasks/task-invocation-lab/til-cache-source.json new file mode 100644 index 0000000..58bb3da --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-cache-source.json @@ -0,0 +1,23 @@ +{ + "key": "til-cache-source", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "http", + "mocklab", + "cache-source" + ], + "attributes": { + "type": "6", + "config": { + "url": "http://localhost:3001/api/til/cache-source", + "method": "GET", + "timeoutSeconds": 10, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-cacheaside.json b/core/Tasks/task-invocation-lab/til-cacheaside.json new file mode 100644 index 0000000..cf94b2d --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-cacheaside.json @@ -0,0 +1,30 @@ +{ + "key": "til-cacheaside", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "cacheaside", + "statestore" + ], + "attributes": { + "type": "18", + "config": { + "key": "til:cacheaside:roundtrip", + "ttlInSeconds": 60, + "sourceTask": { + "key": "til-cache-source", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "sourceMapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-dapr-ok.json b/core/Tasks/task-invocation-lab/til-dapr-ok.json new file mode 100644 index 0000000..f2e6c7f --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-dapr-ok.json @@ -0,0 +1,29 @@ +{ + "key": "til-dapr-ok", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "dapr", + "mocklab", + "success" + ], + "attributes": { + "type": "3", + "config": { + "appId": "mocklab", + "methodName": "api/til/ok", + "httpVerb": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "source": "task-invocation-lab" + }, + "timeoutSeconds": 10 + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-http-500.json b/core/Tasks/task-invocation-lab/til-http-500.json new file mode 100644 index 0000000..bab90a3 --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-http-500.json @@ -0,0 +1,30 @@ +{ + "key": "til-http-500", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "http", + "mocklab", + "always-500" + ], + "attributes": { + "type": "6", + "config": { + "url": "API_BASEURL/api/til/fail-500", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "source": "task-invocation-lab" + }, + "contentType": "application/json", + "timeoutSeconds": 10, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-http-ok.json b/core/Tasks/task-invocation-lab/til-http-ok.json new file mode 100644 index 0000000..d032223 --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-http-ok.json @@ -0,0 +1,30 @@ +{ + "key": "til-http-ok", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "http", + "mocklab", + "success" + ], + "attributes": { + "type": "6", + "config": { + "url": "API_BASEURL/api/til/ok", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "source": "task-invocation-lab" + }, + "contentType": "application/json", + "timeoutSeconds": 10, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-http-slow.json b/core/Tasks/task-invocation-lab/til-http-slow.json new file mode 100644 index 0000000..189bfe9 --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-http-slow.json @@ -0,0 +1,30 @@ +{ + "key": "til-http-slow", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "http", + "mocklab", + "timeout" + ], + "attributes": { + "type": "6", + "config": { + "url": "API_BASEURL/api/til/slow", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "source": "task-invocation-lab" + }, + "contentType": "application/json", + "timeoutSeconds": 2, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-soap-fault.json b/core/Tasks/task-invocation-lab/til-soap-fault.json new file mode 100644 index 0000000..000fe9e --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-soap-fault.json @@ -0,0 +1,25 @@ +{ + "key": "til-soap-fault", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "soap", + "mocklab", + "fault" + ], + "attributes": { + "type": "16", + "config": { + "url": "API_BASEURL/api/til/soap-fault", + "soapAction": "TilPing", + "soapVersion": "1.1", + "body": "\n\n \n \n fault-please\n \n \n", + "timeoutSeconds": 10, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-soap-ok.json b/core/Tasks/task-invocation-lab/til-soap-ok.json new file mode 100644 index 0000000..47718a1 --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-soap-ok.json @@ -0,0 +1,25 @@ +{ + "key": "til-soap-ok", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "soap", + "mocklab", + "success" + ], + "attributes": { + "type": "16", + "config": { + "url": "API_BASEURL/api/til/soap-ok", + "soapAction": "TilPing", + "soapVersion": "1.1", + "body": "\n\n \n \n ping\n \n \n", + "timeoutSeconds": 10, + "validateSsl": true + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-statestore-get.json b/core/Tasks/task-invocation-lab/til-statestore-get.json new file mode 100644 index 0000000..3bce76b --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-statestore-get.json @@ -0,0 +1,20 @@ +{ + "key": "til-statestore-get", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "statestore", + "get" + ], + "attributes": { + "type": "17", + "config": { + "command": "get", + "key": "til:statestore:roundtrip" + } + } +} diff --git a/core/Tasks/task-invocation-lab/til-statestore-set.json b/core/Tasks/task-invocation-lab/til-statestore-set.json new file mode 100644 index 0000000..b4f124c --- /dev/null +++ b/core/Tasks/task-invocation-lab/til-statestore-set.json @@ -0,0 +1,25 @@ +{ + "key": "til-statestore-set", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "task-invocation-lab", + "statestore", + "set" + ], + "attributes": { + "type": "17", + "config": { + "command": "set", + "key": "til:statestore:roundtrip", + "value": { + "source": "til-statestore-set", + "marker": "til-roundtrip-value" + }, + "ttlInSeconds": 300 + } + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-leaf.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-leaf.json new file mode 100644 index 0000000..fd22a47 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-leaf.json @@ -0,0 +1,156 @@ +{ + "key": "authorization-chain-lab-leaf", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "leaf" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Leaf" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": "start-authorization-chain-lab-leaf", + "target": "leaf-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Leaf" + } + ] + }, + "states": [ + { + "key": "leaf-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "leaf initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-leaf-to-leaf-waiting", + "target": "leaf-waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "leaf-waiting", + "stateType": 2, + "subType": 6, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Leaf Waiting" + } + ], + "view": null, + "subFlow": null, + "interaction": { + "longPoll": { + "terminate": true, + "fallbackTimeoutSeconds": 30, + "roles": [ + { + "role": "chain.admin", + "grant": "allow" + } + ] + } + }, + "transitions": [ + { + "key": "finish-leaf", + "target": "leaf-done", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Finish Leaf" + } + ] + } + ] + }, + { + "key": "leaf-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "leaf done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "leaf-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "leaf cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.leaf-only", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid-terminal.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid-terminal.json new file mode 100644 index 0000000..aa983a9 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid-terminal.json @@ -0,0 +1,144 @@ +{ + "key": "authorization-chain-lab-mid-terminal", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "mid-terminal" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Mid (terminal)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": "start-authorization-chain-lab-mid-terminal", + "target": "mid-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Mid Terminal" + } + ] + }, + "states": [ + { + "key": "mid-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-mid-to-mid-waiting", + "target": "mid-waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "mid-waiting", + "stateType": 2, + "subType": 6, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Mid Waiting (terminal)" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "finish-mid", + "target": "mid-done", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Finish Mid" + } + ] + } + ] + }, + { + "key": "mid-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "mid-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-only", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid.json new file mode 100644 index 0000000..46274f7 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-mid.json @@ -0,0 +1,163 @@ +{ + "key": "authorization-chain-lab-mid", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "mid" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Mid" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": "start-authorization-chain-lab-mid", + "target": "mid-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Mid" + } + ] + }, + "states": [ + { + "key": "mid-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-mid-to-mid-waiting", + "target": "mid-waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "mid-waiting", + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid-waiting" + } + ], + "view": null, + "transitions": [], + "subFlow": { + "type": "S", + "process": { + "key": "authorization-chain-lab-leaf", + "domain": "core", + "version": "1.0.1", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/ChainSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLyBQYXNzLXRocm91Z2g6IHRoaXMgbGFiIG1lYXN1cmVzIGF1dGhvcml6YXRpb24sIG5vdCBkYXRhIGZsb3cuIFRoZSBjaGlsZCBvbmx5IG5lZWRzIHRvCi8vIHN0YXJ0OyBub3RoaW5nIGRvd25zdHJlYW0gcmVhZHMgd2hhdCBpdCB3YXMgc3RhcnRlZCB3aXRoLgpwdWJsaWMgY2xhc3MgQ2hhaW5TdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICBkeW5hbWljIGlucHV0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBpbnB1dC5zdGFydGVkQnlDaGFpbkxhYiA9IHRydWU7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gaW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgICAgID0+IFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7Cn0K" + }, + "overrides": { + "states": { + "leaf-waiting": { + "queryRoles": [ + { + "role": "chain.leaf-admin", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + } + ] + } + } + } + } + }, + { + "key": "mid-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "mid-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "mid cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-only", + "grant": "allow" + }, + { + "role": "chain.mid-admin", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-narrow.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-narrow.json new file mode 100644 index 0000000..41b09f3 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-narrow.json @@ -0,0 +1,191 @@ +{ + "key": "authorization-chain-lab-root-narrow", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "root-narrow" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Two-Level Root" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-root", + "target": "root-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel" + } + ] + }, + "updateData": { + "key": "update-root-data", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Update Data" + } + ] + }, + "exit": { + "key": "exit-root", + "target": "root-done", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Exit" + } + ] + }, + "startTransition": { + "key": "start-authorization-chain-lab-root-narrow", + "target": "root-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start" + } + ] + }, + "states": [ + { + "key": "root-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-root-to-waiting", + "target": "waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "waiting", + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "waiting" + } + ], + "view": null, + "transitions": [], + "subFlow": { + "type": "S", + "process": { + "key": "authorization-chain-lab-mid-terminal", + "domain": "core", + "version": "1.0.1", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/ChainSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLyBQYXNzLXRocm91Z2g6IHRoaXMgbGFiIG1lYXN1cmVzIGF1dGhvcml6YXRpb24sIG5vdCBkYXRhIGZsb3cuIFRoZSBjaGlsZCBvbmx5IG5lZWRzIHRvCi8vIHN0YXJ0OyBub3RoaW5nIGRvd25zdHJlYW0gcmVhZHMgd2hhdCBpdCB3YXMgc3RhcnRlZCB3aXRoLgpwdWJsaWMgY2xhc3MgQ2hhaW5TdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICBkeW5hbWljIGlucHV0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBpbnB1dC5zdGFydGVkQnlDaGFpbkxhYiA9IHRydWU7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gaW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgICAgID0+IFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7Cn0K" + }, + "overrides": { + "states": { + "mid-waiting": { + "queryRoles": [ + { + "role": "chain.admin", + "grant": "allow" + } + ] + } + } + } + } + }, + { + "key": "root-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "root-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-only", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-plain.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-plain.json new file mode 100644 index 0000000..5ce30fe --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root-plain.json @@ -0,0 +1,179 @@ +{ + "key": "authorization-chain-lab-root-plain", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "root-plain" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Two-Level Root" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-root", + "target": "root-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel" + } + ] + }, + "updateData": { + "key": "update-root-data", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Update Data" + } + ] + }, + "exit": { + "key": "exit-root", + "target": "root-done", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Exit" + } + ] + }, + "startTransition": { + "key": "start-authorization-chain-lab-root-plain", + "target": "root-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start" + } + ] + }, + "states": [ + { + "key": "root-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-root-to-waiting", + "target": "waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "waiting", + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "waiting" + } + ], + "view": null, + "transitions": [], + "subFlow": { + "type": "S", + "process": { + "key": "authorization-chain-lab-mid-terminal", + "domain": "core", + "version": "1.0.1", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/ChainSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLyBQYXNzLXRocm91Z2g6IHRoaXMgbGFiIG1lYXN1cmVzIGF1dGhvcml6YXRpb24sIG5vdCBkYXRhIGZsb3cuIFRoZSBjaGlsZCBvbmx5IG5lZWRzIHRvCi8vIHN0YXJ0OyBub3RoaW5nIGRvd25zdHJlYW0gcmVhZHMgd2hhdCBpdCB3YXMgc3RhcnRlZCB3aXRoLgpwdWJsaWMgY2xhc3MgQ2hhaW5TdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICBkeW5hbWljIGlucHV0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBpbnB1dC5zdGFydGVkQnlDaGFpbkxhYiA9IHRydWU7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gaW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgICAgID0+IFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7Cn0K" + } + } + }, + { + "key": "root-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "root-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-only", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/authorization-chain-lab-root.json b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root.json new file mode 100644 index 0000000..7e50886 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/authorization-chain-lab-root.json @@ -0,0 +1,221 @@ +{ + "key": "authorization-chain-lab-root", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "authorization-chain-lab", + "root" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Authorization Chain Lab Root" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [ + { + "key": "record-note", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Record Note" + } + ], + "availableIn": [ + "waiting" + ], + "roles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + } + ] + } + ], + "cancel": { + "key": "cancel-root", + "target": "root-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel" + } + ] + }, + "updateData": { + "key": "update-root-data", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Update Data" + } + ] + }, + "exit": { + "key": "exit-root", + "target": "root-done", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Exit" + } + ] + }, + "startTransition": { + "key": "start-authorization-chain-lab-root", + "target": "root-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start" + } + ] + }, + "states": [ + { + "key": "root-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root initial" + } + ], + "view": null, + "subFlow": null, + "transitions": [ + { + "key": "auto-root-to-waiting", + "target": "waiting", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto" + } + ], + "rule": { + "location": "./src/ChainAlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vIFRoZSBjaGFpbiBtdXN0IGFzc2VtYmxlIGl0c2VsZiBvbiBzdGFydDogYW4gYXV0aG9yaXphdGlvbiB0ZXN0IHdhbnRzIHRoZSBpbnN0YW5jZSBhbHJlYWR5Ci8vIHNpdHRpbmcgYXQgdGhlIGRlZXBlc3QgbGVhZiwgbm90IGEgZml4dHVyZSB0aGUgdGVzdCBoYXMgdG8gZHJpdmUgaG9wIGJ5IGhvcC4KcHVibGljIGNsYXNzIENoYWluQWx3YXlzVHJ1ZVJ1bGUgOiBTY3JpcHRCYXNlLCBJQ29uZGl0aW9uTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxib29sPiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkgPT4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwp9Cg==" + } + } + ] + }, + { + "key": "waiting", + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "waiting" + } + ], + "view": null, + "transitions": [], + "subFlow": { + "type": "S", + "process": { + "key": "authorization-chain-lab-mid", + "domain": "core", + "version": "1.0.1", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/ChainSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLyBQYXNzLXRocm91Z2g6IHRoaXMgbGFiIG1lYXN1cmVzIGF1dGhvcml6YXRpb24sIG5vdCBkYXRhIGZsb3cuIFRoZSBjaGlsZCBvbmx5IG5lZWRzIHRvCi8vIHN0YXJ0OyBub3RoaW5nIGRvd25zdHJlYW0gcmVhZHMgd2hhdCBpdCB3YXMgc3RhcnRlZCB3aXRoLgpwdWJsaWMgY2xhc3MgQ2hhaW5TdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICBkeW5hbWljIGlucHV0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBpbnB1dC5zdGFydGVkQnlDaGFpbkxhYiA9IHRydWU7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gaW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgICAgID0+IFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7Cn0K" + }, + "overrides": { + "states": { + "mid-waiting": { + "queryRoles": [ + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-admin", + "grant": "allow" + } + ] + } + } + } + } + }, + { + "key": "root-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root done" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + }, + { + "key": "root-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "root cancelled" + } + ], + "view": null, + "subFlow": null, + "transitions": [] + } + ], + "queryRoles": [ + { + "role": "chain.reader", + "grant": "allow" + }, + { + "role": "chain.admin", + "grant": "allow" + }, + { + "role": "chain.mid-admin", + "grant": "allow" + } + ] + } +} diff --git a/core/Workflows/authorization-chain-lab/build-authorization-chain-lab.py b/core/Workflows/authorization-chain-lab/build-authorization-chain-lab.py new file mode 100644 index 0000000..4c1e975 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/build-authorization-chain-lab.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +""" +authorization-chain-lab akislarini uretir (csx -> base64 gomulu workflow JSON). + + python3 core/Workflows/authorization-chain-lab/build-authorization-chain-lab.py + +Uretilenler: + core/Workflows/authorization-chain-lab/authorization-chain-lab-root.json + core/Workflows/authorization-chain-lab/authorization-chain-lab-root-plain.json + core/Workflows/authorization-chain-lab/authorization-chain-lab-mid.json + core/Workflows/authorization-chain-lab/authorization-chain-lab-leaf.json + core/Workflows/authorization-chain-lab/src/*.csx + +Bu lab TEK BIR SEYI olcer: `authorize` fonksiyonunun AKTIF KORELASYON ZINCIRI boyunca +verdigi cevabin, okuma yuzeylerinin fiilen uyguladigi kapiyla ayni olup olmadigini. + +Neden ayri bir akis: `subflow-orchestration` zaten A->B->C bir zincir ve A'nin B icin bir +`overrides.states` girdisi var — ama o suite YESIL ve testleri rol header'i gondermeden +okuyor. Ara/yaprak seviyelere `queryRoles` eklemek o testleri kirardi. Bu lab, ayni +zincir sekline sahip AMA her seviyesi yetkilendirme icin tasarlanmis bagimsiz bir kopya. + +Zincir ve grant tasarimi +------------------------ + ROOT (F) waiting --subFlow--> MID (S) mid-waiting --subFlow--> LEAF (S) leaf-waiting + + * ROOT.queryRoles = allow [chain.reader, chain.admin] + * ROOT overrides MID: states["mid-waiting"].queryRoles = allow [chain.admin] + * MID.queryRoles = allow [chain.reader, chain.admin, chain.mid-only] + * MID overrides LEAF: states["leaf-waiting"].queryRoles = allow [chain.leaf-admin] + * LEAF.queryRoles = allow [chain.reader, chain.admin, chain.leaf-only] + +Bu tek zincir, talep edilen UC vakanin ucunu de ayni anda kanitlar: + + (1) A -> B -> C, C'ye kadar inilir. + `chain.reader` ROOT'ta gecer, ROOT'un MID override'inda DUSER => deny. + Iniş olmasaydi ROOT tek basina ALLOW derdi; konjonksiyon boyle gorunur. + + (2) A'nin B'ye override'i C'ye TASINMAZ. + `chain.admin` ROOT'ta gecer, ROOT->MID override'inda gecer, ama LEAF'te MID'in + KENDI override'i (`chain.leaf-admin`) uygulanir => deny. + Eger A'nin override'i asagi tasinsaydi `chain.admin` LEAF'te de gecerdi. + + (3) B, C'ye kendi kurallarina uygun override bildirir ve C'de ele alinir. + `chain.leaf-admin` ROOT'ta DUSER (ROOT.queryRoles'da yok) => zincir zaten orada biter. + Bu yuzden LEAF override'inin tek basina gorunur olmasi icin `root-plain` vardir: + ayni MID/LEAF'i override BILDIRMEDEN baslatir, boylece MID'in kendi queryRoles'u ve + MID'in LEAF override'i yalin halde olculur. + + * `root-plain`: ayni MID'i override'SIZ baslatir. "Override yoksa nesnenin kendi tanimi + uygulanir" yarisini kanitlar — REPLACE semantiginin diger yakasi. + + * LEAF `leaf-waiting` state'i `interaction.longPoll` tasir (roles kolu = [chain.admin]). + `authorize?ack=true` ile ack endpoint'inin AYNI verdikti verdigini olcmek icin. + RULE kolu burada YOK: sema onu tanimiyor (bkz. main()'deki not). + + * ROOT `cancel` / `updateData` / `exit` ve bir `record-note` shared transition'i tasir: + aktif subflow varken bunlarin PARENT'ta cevaplandigini (inilmedigini) olcmek icin — + execution tarafinda da boyle davranirlar. +""" + +import base64 +import json +import pathlib + +HERE = pathlib.Path(__file__).resolve().parent +SRC = HERE / "src" +SRC.mkdir(exist_ok=True) + +ALLOW = "allow" + +READER = "chain.reader" +ADMIN = "chain.admin" +MID_ONLY = "chain.mid-only" +LEAF_ONLY = "chain.leaf-only" +LEAF_ADMIN = "chain.leaf-admin" +# Granted by the ROOT's override of the mid and by the mid's OWN grants, but deliberately absent from +# the mid's override of the leaf. It is the probe for "an ancestor's override does not travel past its +# direct child": if the root's narrowing reached the leaf, this role would be admitted there. +MID_ADMIN = "chain.mid-admin" + + +def grants(*roles, grant=ALLOW): + return [{"role": r, "grant": grant} for r in roles] + + +def label(text): + return [{"language": "en-US", "label": text}] + + +def csx(name: str, body: str) -> dict: + """Writes the script beside the flow and embeds it base64, as every other lab does.""" + path = SRC / f"{name}.csx" + path.write_text(body, encoding="utf-8") + return { + "location": f"./src/{name}.csx", + "code": base64.b64encode(body.encode("utf-8")).decode("ascii"), + } + + +SUBFLOW_MAPPING = """using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +// Pass-through: this lab measures authorization, not data flow. The child only needs to +// start; nothing downstream reads what it was started with. +public class ChainSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + dynamic input = new ExpandoObject(); + input.startedByChainLab = true; + return Task.FromResult(new ScriptResponse { Data = input }); + } + + public Task OutputHandler(ScriptContext context) + => Task.FromResult(new ScriptResponse()); +} +""" + +ALWAYS_TRUE = """using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +// The chain must assemble itself on start: an authorization test wants the instance already +// sitting at the deepest leaf, not a fixture the test has to drive hop by hop. +public class ChainAlwaysTrueRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) => Task.FromResult(true); +} +""" + +ACK_RULE = """using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +// The RULE arm of interaction.longPoll. Deliberately keyed on a header rather than on +// instance data: the point of the arm is that it can read things no role set contains, and +// a header is the cheapest way for a test to flip it. Fail-closed by contract — anything +// other than an explicit "yes" denies. +public class ChainAckRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var header = context.Headers != null && context.Headers.ContainsKey("x-chain-ack") + ? context.Headers["x-chain-ack"] + : null; + + return Task.FromResult(header == "yes"); + } +} +""" + + +# Publish is VERSION-IMMUTABLE: re-publishing the same key at the same version is a 409 +# (Instance:100002) that the tooling reports as success-with-failures, so an edited flow silently +# keeps serving the old definition. Every fixture change needs a patch bump — this constant is the +# one place to do it, and the subflow `process.version` references below read it too. +FIXTURE_VERSION = "1.0.1" + + +def envelope(key: str, attributes: dict, extra_tags=()) -> dict: + return { + "key": key, + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": FIXTURE_VERSION, + "tags": ["integration-test", "authorization-chain-lab", *extra_tags], + "attributes": attributes, + } + + +def initial_state(prefix: str, target: str): + """ + Every flow needs exactly one stateType 1. It carries the automatic hop that walks the chain + down, so a test only has to start the root and wait for the leaf. + """ + return { + "key": f"{prefix}-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": label(f"{prefix} initial"), + "view": None, + "subFlow": None, + "transitions": [ + { + "key": f"auto-{prefix}-to-{target}", + "target": target, + "triggerType": 1, + "versionStrategy": "Minor", + "labels": label("Auto"), + "rule": csx("ChainAlwaysTrueRule", ALWAYS_TRUE), + } + ], + } + + +def finish_states(prefix: str): + return [ + { + "key": f"{prefix}-done", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": label(f"{prefix} done"), + "view": None, + "subFlow": None, + "transitions": [], + }, + { + "key": f"{prefix}-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": label(f"{prefix} cancelled"), + "view": None, + "subFlow": None, + "transitions": [], + }, + ] + + +def subflow_state(key: str, child_key: str, mapping: dict, overrides=None): + state = { + "key": key, + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": label(key), + "view": None, + "transitions": [], + "subFlow": { + "type": "S", + "process": { + "key": child_key, + "domain": "core", + "version": FIXTURE_VERSION, + "flow": "sys-flows", + }, + "mapping": mapping, + }, + } + if overrides is not None: + state["subFlow"]["overrides"] = overrides + return state + + +def well_known(prefix: str): + """cancel / updateData / exit on the root, so the parent-retention rule is measurable.""" + return { + "cancel": { + "key": f"cancel-{prefix}", + "target": f"{prefix}-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Cancel"), + }, + "updateData": { + "key": f"update-{prefix}-data", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Update Data"), + }, + "exit": { + "key": f"exit-{prefix}", + "target": f"{prefix}-done", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Exit"), + }, + } + + +def build_root(key: str, overrides, tag: str): + mapping = csx("ChainSubFlowMapping", SUBFLOW_MAPPING) + attributes = { + "type": "F", + "timeout": None, + "labels": label("Authorization Chain Lab Root"), + "functions": [], + "features": [], + "extensions": [], + # A shared transition available everywhere: with an active SubFlow the parent RETAINS + # it, so authorize must answer against the root rather than descending. + "sharedTransitions": [ + { + "key": "record-note", + "target": "$self", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": label("Record Note"), + "availableIn": ["waiting"], + "roles": grants(READER, ADMIN), + } + ], + **well_known("root"), + "startTransition": { + "key": f"start-{key}", + "target": "root-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start"), + }, + "states": [ + initial_state("root", "waiting"), + subflow_state("waiting", "authorization-chain-lab-mid", mapping, overrides), + *finish_states("root"), + ], + # ROOT allowlist. `chain.leaf-admin` and `chain.mid-only` are deliberately ABSENT: + # a caller who would pass deeper down still has to get past the root first, which is + # what makes the conjunction observable from the top. + "queryRoles": grants(READER, ADMIN, MID_ADMIN), + } + return envelope(key, attributes, (tag,)) + + +def build_mid(): + mapping = csx("ChainSubFlowMapping", SUBFLOW_MAPPING) + attributes = { + "type": "S", + "timeout": None, + "labels": label("Authorization Chain Lab Mid"), + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": "start-authorization-chain-lab-mid", + "target": "mid-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start Mid"), + }, + "states": [ + initial_state("mid", "mid-waiting"), + subflow_state( + "mid-waiting", + "authorization-chain-lab-leaf", + mapping, + # MID declares its OWN override for LEAF. This is the half that proves a + # parent's override does not travel further down: ROOT narrowed MID to + # chain.admin, and MID independently narrows LEAF to chain.leaf-admin. + # ADMIN is included so ONE role survives the whole chain. Without it the leaf + # refuses everyone and the conjunction denies at every level above, which makes + # the root/mid distinctions unobservable — the first version of this fixture had + # exactly that flaw and most of its assertions were unprovable rather than wrong. + {"states": {"leaf-waiting": {"queryRoles": grants(LEAF_ADMIN, ADMIN)}}}, + ), + *finish_states("mid"), + ], + "queryRoles": grants(READER, ADMIN, MID_ONLY, MID_ADMIN), + } + return envelope("authorization-chain-lab-mid", attributes, ("mid",)) + + +def build_mid_terminal(): + """ + A mid with NO SubFlow beneath it. + + The override semantics can only be observed on an instance that has no active SubFlow of its + own. `IsQueryAllowedAsync` keys the lookup on `EffectiveState`, which for a parent is a + DESCENDANT's state — so the stamped override (keyed by the state the parent declared) never + matches while the child is itself parked on a subflow, and the gate degrades to the workflow + root's grants. Measured on the bench: the root narrows the mid to chain.admin and chain.mid-only + still reads the mid 200. + + + That is a runtime defect and it is NOT this lab's subject, so the override tests address a level + where the mechanism is reachable. When the defect is fixed, the three-level assertions can move + back up. + + """ + attributes = { + "type": "S", + "timeout": None, + "labels": label("Authorization Chain Lab Mid (terminal)"), + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": "start-authorization-chain-lab-mid-terminal", + "target": "mid-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start Mid Terminal"), + }, + "states": [ + initial_state("mid", "mid-waiting"), + { + "key": "mid-waiting", + "stateType": 2, + "subType": 6, + "versionStrategy": "Major", + "labels": label("Mid Waiting (terminal)"), + "view": None, + "subFlow": None, + "transitions": [ + { + "key": "finish-mid", + "target": "mid-done", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": label("Finish Mid"), + } + ], + }, + *finish_states("mid"), + ], + "queryRoles": grants(READER, ADMIN, MID_ONLY), + } + return envelope("authorization-chain-lab-mid-terminal", attributes, ("mid-terminal",)) + + +def build_two_level_root(key: str, overrides, tag: str): + """A root whose child is the TERMINAL mid — two levels, so the child's gate is reachable.""" + mapping = csx("ChainSubFlowMapping", SUBFLOW_MAPPING) + attributes = { + "type": "F", + "timeout": None, + "labels": label("Authorization Chain Lab Two-Level Root"), + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + **well_known("root"), + "startTransition": { + "key": f"start-{key}", + "target": "root-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start"), + }, + "states": [ + initial_state("root", "waiting"), + subflow_state("waiting", "authorization-chain-lab-mid-terminal", mapping, overrides), + *finish_states("root"), + ], + "queryRoles": grants(READER, ADMIN, MID_ONLY), + } + return envelope(key, attributes, (tag,)) + + +def build_leaf(key: str, interaction: dict, tag: str): + attributes = { + "type": "S", + "timeout": None, + "labels": label("Authorization Chain Lab Leaf"), + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "startTransition": { + "key": f"start-{key}", + "target": "leaf-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start Leaf"), + }, + "states": [ + initial_state("leaf", "leaf-waiting"), + { + "key": "leaf-waiting", + "stateType": 2, + "subType": 6, + "versionStrategy": "Major", + "labels": label("Leaf Waiting"), + "view": None, + "subFlow": None, + "interaction": interaction, + "transitions": [ + { + "key": "finish-leaf", + "target": "leaf-done", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": label("Finish Leaf"), + } + ], + }, + *finish_states("leaf"), + ], + "queryRoles": grants(READER, ADMIN, LEAF_ONLY), + } + return envelope(key, attributes, (tag,)) + + +def write(doc: dict): + path = HERE / f"{doc['key']}.json" + path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print("wrote", path.relative_to(HERE.parents[2])) + + +def main(): + write(build_root( + "authorization-chain-lab-root", + {"states": {"mid-waiting": {"queryRoles": grants(ADMIN, MID_ADMIN)}}}, + "root", + )) + # The A/B partner: same MID and LEAF, started with NO overrides, so the mid's own + # queryRoles apply. Without this the "override REPLACES" assertion has no control group. + # Two-level pair: same terminal child, one root overriding it and one not. This is the only + # shape in which "override REPLACES" and "no override → own grants" are both observable today. + write(build_two_level_root("authorization-chain-lab-root-plain", None, "root-plain")) + write(build_two_level_root( + "authorization-chain-lab-root-narrow", + {"states": {"mid-waiting": {"queryRoles": grants(ADMIN)}}}, + "root-narrow", + )) + write(build_mid_terminal()) + write(build_mid()) + write(build_leaf( + "authorization-chain-lab-leaf", + {"longPoll": {"terminate": True, "fallbackTimeoutSeconds": 30, "roles": grants(ADMIN)}}, + "leaf", + )) + # NOT BUILT: a `rule`-arm leaf. `interaction.longPoll.rule` shipped in the runtime with + # issue #936 (0.0.92) but `@burgan-tech/vnext-schema` never grew the property — the installed + # 0.0.52 AND the sibling repo at 1.0.0 both declare `required: [terminate, roles]` with + # `additionalProperties: false`, so a rule-armed state cannot pass `npm run validate` and no + # domain team can author one. `vnext-meta/features.json` claims the opposite ("the vnext-schema + # longPoll contract enforces exactly one of roles|rule at authoring time"); that claim is false. + # Rule-arm parity for `authorize?ack=true` is therefore covered by unit tests until the schema + # ships the arm. Do not add it back here before checking the installed schema version. + + +if __name__ == "__main__": + main() diff --git a/core/Workflows/authorization-chain-lab/src/ChainAlwaysTrueRule.csx b/core/Workflows/authorization-chain-lab/src/ChainAlwaysTrueRule.csx new file mode 100644 index 0000000..bc8b985 --- /dev/null +++ b/core/Workflows/authorization-chain-lab/src/ChainAlwaysTrueRule.csx @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +// The chain must assemble itself on start: an authorization test wants the instance already +// sitting at the deepest leaf, not a fixture the test has to drive hop by hop. +public class ChainAlwaysTrueRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) => Task.FromResult(true); +} diff --git a/core/Workflows/authorization-chain-lab/src/ChainSubFlowMapping.csx b/core/Workflows/authorization-chain-lab/src/ChainSubFlowMapping.csx new file mode 100644 index 0000000..31e787f --- /dev/null +++ b/core/Workflows/authorization-chain-lab/src/ChainSubFlowMapping.csx @@ -0,0 +1,18 @@ +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +// Pass-through: this lab measures authorization, not data flow. The child only needs to +// start; nothing downstream reads what it was started with. +public class ChainSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + dynamic input = new ExpandoObject(); + input.startedByChainLab = true; + return Task.FromResult(new ScriptResponse { Data = input }); + } + + public Task OutputHandler(ScriptContext context) + => Task.FromResult(new ScriptResponse()); +} diff --git a/core/Workflows/cross-domain-lab/.meta/xd-parent.diagram.json b/core/Workflows/cross-domain-lab/.meta/xd-parent.diagram.json new file mode 100644 index 0000000..00a4467 --- /dev/null +++ b/core/Workflows/cross-domain-lab/.meta/xd-parent.diagram.json @@ -0,0 +1,3 @@ +{ + "nodePos": {} +} \ No newline at end of file diff --git a/core/Workflows/error-boundary-lab/error-boundary-lab-global.json b/core/Workflows/error-boundary-lab/error-boundary-lab-global.json index eef6f01..fd4718e 100644 --- a/core/Workflows/error-boundary-lab/error-boundary-lab-global.json +++ b/core/Workflows/error-boundary-lab/error-boundary-lab-global.json @@ -285,4 +285,4 @@ ] } } -} +} \ No newline at end of file diff --git a/core/Workflows/error-boundary-lab/error-boundary-lab.json b/core/Workflows/error-boundary-lab/error-boundary-lab.json index 108f325..8ecbb01 100644 --- a/core/Workflows/error-boundary-lab/error-boundary-lab.json +++ b/core/Workflows/error-boundary-lab/error-boundary-lab.json @@ -792,4 +792,4 @@ ] } } -} +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/.meta/ht-a.diagram.json b/core/Workflows/human-task-chain/.meta/ht-a.diagram.json new file mode 100644 index 0000000..00a4467 --- /dev/null +++ b/core/Workflows/human-task-chain/.meta/ht-a.diagram.json @@ -0,0 +1,3 @@ +{ + "nodePos": {} +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/.meta/ht-b.diagram.json b/core/Workflows/human-task-chain/.meta/ht-b.diagram.json new file mode 100644 index 0000000..00a4467 --- /dev/null +++ b/core/Workflows/human-task-chain/.meta/ht-b.diagram.json @@ -0,0 +1,3 @@ +{ + "nodePos": {} +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/.meta/ht-c.diagram.json b/core/Workflows/human-task-chain/.meta/ht-c.diagram.json new file mode 100644 index 0000000..00a4467 --- /dev/null +++ b/core/Workflows/human-task-chain/.meta/ht-c.diagram.json @@ -0,0 +1,3 @@ +{ + "nodePos": {} +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/ht-a.json b/core/Workflows/human-task-chain/ht-a.json new file mode 100644 index 0000000..fa80968 --- /dev/null +++ b/core/Workflows/human-task-chain/ht-a.json @@ -0,0 +1,317 @@ +{ + "key": "ht-a", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "core" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-A (core)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-a", + "target": "ht-a-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-a" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-a-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-a", + "target": "ht-a-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-a" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-a-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-a-spawn-process", + "target": "ht-a-spawn", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: spawn a SubProcess" + } + ], + "rule": { + "location": "./src/SpawnProcessRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBGaXJlcyB0aGUgcm9vdCdzIFN1YlByb2Nlc3MgYnJhbmNoIHdoZW4gdGhlIHBheWxvYWQgYXNrcyBmb3IgaXQgKDxjPm1vZGUgPSAicHJvY2VzcyI8L2M+KS4KLy8vIEEgU3ViUHJvY2VzcyBpcyBmaXJlLWFuZC1mb3JnZXQ6IHRoZSByb290IGRvZXMgbm90IHdhaXQgZm9yIGl0IGFuZCBub3RoaW5nIHByb2plY3RzIGl0cyBzdGF0ZQovLy8gdXB3YXJkLCBzbyB0aGUgdHdvIGJlY29tZSBpbmRlcGVuZGVudCB1bml0cyBvZiB3b3JrIGFuZCB0aGUgbGlzdCBtdXN0IGNhcnJ5IEJPVEguCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBTcGF3blByb2Nlc3NSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgc3Bhd24gPSBkYXRhICE9IG51bGwKICAgICAgICAgICAgICAgICAgICAmJiBkYXRhLlRyeUdldFZhbHVlKCJtb2RlIiwgb3V0IHZhciBtb2RlKQogICAgICAgICAgICAgICAgICAgICYmIG1vZGUgIT0gbnVsbAogICAgICAgICAgICAgICAgICAgICYmIG1vZGUuVG9TdHJpbmcoKSA9PSAicHJvY2VzcyI7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiU3Bhd25Qcm9jZXNzUnVsZTogc3Bhd249e3NwYXdufSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQoc3Bhd24pOwogICAgfQp9Cg==" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-a-descend", + "target": "ht-a-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: descend to ht-b" + } + ], + "rule": { + "location": "./src/DescendRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBEZXNjZW5kIHdoaWxlIGBob3BzYCBpcyBwb3NpdGl2ZS4gRXZlcnkgc3ViZmxvdyBtYXBwaW5nIGRlY3JlbWVudHMgaXQsIHNvIHRoZSBwYXlsb2FkJ3MgaW5pdGlhbAovLy8gdmFsdWUgYWxvbmUgZGVjaWRlcyB3aGljaCBsZXZlbCBlbmRzIHVwIGhvbGRpbmcgdGhlIGh1bWFuIHRhc2sg4oCUIHRoZSBkZWZpbml0aW9uIGNoYWluIGlzIHRoZQovLy8gc2FtZSBmb3IgYWxsIHRocmVlIHNjZW5hcmlvcy4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIERlc2NlbmRSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJob3BzIiwgb3V0IHZhciByYXcpICYmIHJhdyAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgaW50LlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgaG9wcyk7CiAgICAgICAgfQoKICAgICAgICB2YXIgZGVzY2VuZCA9IGhvcHMgPiAwOwogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiRGVzY2VuZFJ1bGU6IGhvcHM9e2hvcHN9IGRlc2NlbmQ9e2Rlc2NlbmR9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChkZXNjZW5kKTsKICAgIH0KfQo=" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-a-to-human", + "target": "ht-a-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-a-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a subflow" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "ht-b", + "domain": "core", + "version": "1.0.6", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/HtaToNextSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gSGFuZHMgdGhlIGNoYWluIGRvd24gb25lIGxldmVsOiBkZWNyZW1lbnRzIGBob3BzYCwgY2FycmllcyBgdGVzdElkYCwgYW5kIGdpdmVzIHRoZSBjaGlsZCBpdHMgT1dOCi8vLyBodW1hblRhc2sgdGV4dC4gVGhlIGRpc3RpbmN0IHRleHQgcGVyIGxldmVsIGlzIHRoZSBwb2ludCDigJQgYSB0ZXN0IGNhbiB0aGVuIHRlbGwgd2hldGhlciB0aGUKLy8vIGxpc3RlZCB0aXRsZSBjYW1lIGZyb20gdGhlIGxlYWYgb3Igd2FzIHRha2VuIGZyb20gYW4gYW5jZXN0b3IuCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBIdGFUb05leHRTdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBob3BzID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoImhvcHMiLCBvdXQgdmFyIHJhdykgJiYgcmF3ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBpbnQuVHJ5UGFyc2UocmF3LlRvU3RyaW5nKCksIG91dCBob3BzKTsKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgY2hpbGRJbnB1dC5ob3BzID0gaG9wcyAtIDE7CgogICAgICAgIGlmIChkYXRhICE9IG51bGwgJiYgZGF0YS5UcnlHZXRWYWx1ZSgidGVzdElkIiwgb3V0IHZhciB0ZXN0SWQpICYmIHRlc3RJZCAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgY2hpbGRJbnB1dC50ZXN0SWQgPSB0ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBkeW5hbWljIGh1bWFuVGFzayA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaHVtYW5UYXNrLnRpdGxlID0gIkhULUIgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUIgc3RlcCBkZXNjcmlwdGlvbiI7CiAgICAgICAgY2hpbGRJbnB1dC5odW1hblRhc2sgPSBodW1hblRhc2s7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiSHRhVG9OZXh0U3ViRmxvd01hcHBpbmc6IGRlc2NlbmRpbmcgdG8gSFQtQiB3aXRoIGhvcHM9e2hvcHMgLSAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-a-subflow-done", + "target": "ht-a-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: subflow finished" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-a-spawn", + "stateType": 2, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a spawn SubProcess" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-a-spawned", + "target": "ht-a-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: carry on after spawning" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "ht-a-spawn-process", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/SpawnProcessMapping.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFRhc2sgMTQgKFN1YlByb2Nlc3MpIG1hcHBpbmcgZm9yIGh0LWEtc3Bhd24tcHJvY2VzcyAtPiBwYXJ0bmVyL2h0LWQsIGZpcmUtYW5kLWZvcmdldCBvdmVyIERhcHIKLy8vIHNlcnZpY2UgaW52b2NhdGlvbi4gSXQgZ2V0cyBpdHMgT1dOIGhvcCBidWRnZXQsIGJlY2F1c2UgaXQgaXMgbm90IGEgY29udGludWF0aW9uIG9mIHRoZSByb290J3MKLy8vIGNoYWluIOKAlCBpdCBpcyBhIHNlcGFyYXRlIHVuaXQgb2Ygd29yayB0aGF0IG1heSBpdHNlbGYgZGVzY2VuZCB0aHJvdWdoIFN1YkZsb3dzLgovLy8gPC9zdW1tYXJ5PgovLy8gPHJlbWFya3M+Ci8vLyBBIHN0YXRlIGNhbiBubyBsb25nZXIgc3RhcnQgYSBTdWJQcm9jZXNzIGRpcmVjdGx5ICg8Yz5zdGF0ZS5zdWJGbG93LnR5cGU6ICJQIjwvYz4gaXMgcmVqZWN0ZWQgYXQKLy8vIHB1Ymxpc2ggYnkgdGhlIHJ1bnRpbWUncyB2YWxpZGF0b3Ig4oCUIGEgc3RhdGUgc3RhcnRzIGEgU3ViRmxvdyBhbmQgb25seSBhIFN1YkZsb3cpLiBTdWJQcm9jZXNzCi8vLyBzdGFydCBpcyBub3cgZXhwcmVzc2VkIGFzIHRoaXMgPGM+U3ViUHJvY2Vzc1Rhc2s8L2M+ICh0eXBlICIxNCIpIHdpcmVkIG9uIHRoZSB0cmFuc2l0aW9uJ3MKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3M8L2M+LCB3aGljaCBpcyB3aHkgdGhpcyBtYXBwaW5nIGltcGxlbWVudHMgPGM+SU1hcHBpbmc8L2M+IGFuZCBjYXN0cyBpdHMgdGFzawovLy8gdG8gPGM+U3ViUHJvY2Vzc1Rhc2s8L2M+IGluc3RlYWQgb2YgaW1wbGVtZW50aW5nIDxjPklTdWJQcm9jZXNzTWFwcGluZzwvYz4gYWdhaW5zdCBhCi8vLyA8Yz5zdWJGbG93LnByb2Nlc3M8L2M+IGJsb2NrLgovLy8gPC9yZW1hcmtzPgpwdWJsaWMgY2xhc3MgU3Bhd25Qcm9jZXNzTWFwcGluZyA6IFNjcmlwdEJhc2UsIElNYXBwaW5nCnsKICAgIHB1YmxpYyBUYXNrPFNjcmlwdFJlc3BvbnNlPiBJbnB1dEhhbmRsZXIoV29ya2Zsb3dUYXNrIHRhc2ssIFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgc3ViID0gdGFzayBhcyBTdWJQcm9jZXNzVGFzayA/PyB0aHJvdyBuZXcgSW52YWxpZE9wZXJhdGlvbkV4Y2VwdGlvbigiVGFzayBtdXN0IGJlIGEgU3ViUHJvY2Vzc1Rhc2siKTsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2U/LkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJwcm9jZXNzSG9wcyIsIG91dCB2YXIgcmF3KSAmJiByYXcgIT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIGludC5UcnlQYXJzZShyYXcuVG9TdHJpbmcoKSwgb3V0IGhvcHMpOwogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyBib2R5ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBib2R5LmhvcHMgPSBob3BzOwoKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoInRlc3RJZCIsIG91dCB2YXIgdGVzdElkKSAmJiB0ZXN0SWQgIT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIGJvZHkudGVzdElkID0gdGVzdElkOwogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyBodW1hblRhc2sgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIGh1bWFuVGFzay50aXRsZSA9ICJIVC1EIHByb2Nlc3Mgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUQgcHJvY2VzcyBzdGVwIGRlc2NyaXB0aW9uIjsKICAgICAgICBib2R5Lmh1bWFuVGFzayA9IGh1bWFuVGFzazsKCiAgICAgICAgc3ViLlNldERvbWFpbigicGFydG5lciIpOwogICAgICAgIHN1Yi5TZXRGbG93KCJodC1kIik7CiAgICAgICAgc3ViLlNldFZlcnNpb24oIjEuMC42Iik7CiAgICAgICAgc3ViLlNldFVzZURhcHIodHJ1ZSk7CiAgICAgICAgc3ViLlNldFN5bmMoZmFsc2UpOwogICAgICAgIHN1Yi5TZXRCb2R5KGJvZHkpOwoKICAgICAgICBMb2dJbmZvcm1hdGlvbigkIlNwYXduUHJvY2Vzc01hcHBpbmc6IHNwYXduaW5nIGh0LWQgYXMgU3ViUHJvY2VzcyB3aXRoIGhvcHM9e2hvcHN9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIC8vIEZpcmUtYW5kLWZvcmdldDogdGhlIFN1YlByb2Nlc3MgaXMgYW4gaW5kZXBlbmRlbnQgdW5pdCBvZiB3b3JrIGFuZCBub3RoaW5nIHByb2plY3RzIGl0cwogICAgICAgIC8vIHN0YXRlIHVwd2FyZCwgc28gdGhlcmUgaXMgbm90aGluZyB0byBtZXJnZSBiYWNrIGludG8gaHQtYSdzIG93biBkYXRhLgogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + } + } + ] + } + ] + }, + { + "key": "ht-a-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-a-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-a-approve", + "target": "ht-a-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-a-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-a-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-a-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-a cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/ht-b.json b/core/Workflows/human-task-chain/ht-b.json new file mode 100644 index 0000000..d72d989 --- /dev/null +++ b/core/Workflows/human-task-chain/ht-b.json @@ -0,0 +1,254 @@ +{ + "key": "ht-b", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "core" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-B (core)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-b", + "target": "ht-b-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-b" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-b-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-b", + "target": "ht-b-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-b" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-b-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-b-descend", + "target": "ht-b-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b: descend to ht-c" + } + ], + "rule": { + "location": "./src/DescendRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBEZXNjZW5kIHdoaWxlIGBob3BzYCBpcyBwb3NpdGl2ZS4gRXZlcnkgc3ViZmxvdyBtYXBwaW5nIGRlY3JlbWVudHMgaXQsIHNvIHRoZSBwYXlsb2FkJ3MgaW5pdGlhbAovLy8gdmFsdWUgYWxvbmUgZGVjaWRlcyB3aGljaCBsZXZlbCBlbmRzIHVwIGhvbGRpbmcgdGhlIGh1bWFuIHRhc2sg4oCUIHRoZSBkZWZpbml0aW9uIGNoYWluIGlzIHRoZQovLy8gc2FtZSBmb3IgYWxsIHRocmVlIHNjZW5hcmlvcy4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIERlc2NlbmRSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJob3BzIiwgb3V0IHZhciByYXcpICYmIHJhdyAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgaW50LlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgaG9wcyk7CiAgICAgICAgfQoKICAgICAgICB2YXIgZGVzY2VuZCA9IGhvcHMgPiAwOwogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiRGVzY2VuZFJ1bGU6IGhvcHM9e2hvcHN9IGRlc2NlbmQ9e2Rlc2NlbmR9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChkZXNjZW5kKTsKICAgIH0KfQo=" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-b-to-human", + "target": "ht-b-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-b-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b subflow" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "ht-c", + "domain": "core", + "version": "1.0.6", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/HtbToNextSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gSGFuZHMgdGhlIGNoYWluIGRvd24gb25lIGxldmVsOiBkZWNyZW1lbnRzIGBob3BzYCwgY2FycmllcyBgdGVzdElkYCwgYW5kIGdpdmVzIHRoZSBjaGlsZCBpdHMgT1dOCi8vLyBodW1hblRhc2sgdGV4dC4gVGhlIGRpc3RpbmN0IHRleHQgcGVyIGxldmVsIGlzIHRoZSBwb2ludCDigJQgYSB0ZXN0IGNhbiB0aGVuIHRlbGwgd2hldGhlciB0aGUKLy8vIGxpc3RlZCB0aXRsZSBjYW1lIGZyb20gdGhlIGxlYWYgb3Igd2FzIHRha2VuIGZyb20gYW4gYW5jZXN0b3IuCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBIdGJUb05leHRTdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBob3BzID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoImhvcHMiLCBvdXQgdmFyIHJhdykgJiYgcmF3ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBpbnQuVHJ5UGFyc2UocmF3LlRvU3RyaW5nKCksIG91dCBob3BzKTsKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgY2hpbGRJbnB1dC5ob3BzID0gaG9wcyAtIDE7CgogICAgICAgIGlmIChkYXRhICE9IG51bGwgJiYgZGF0YS5UcnlHZXRWYWx1ZSgidGVzdElkIiwgb3V0IHZhciB0ZXN0SWQpICYmIHRlc3RJZCAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgY2hpbGRJbnB1dC50ZXN0SWQgPSB0ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBkeW5hbWljIGh1bWFuVGFzayA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaHVtYW5UYXNrLnRpdGxlID0gIkhULUMgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUMgc3RlcCBkZXNjcmlwdGlvbiI7CiAgICAgICAgY2hpbGRJbnB1dC5odW1hblRhc2sgPSBodW1hblRhc2s7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiSHRiVG9OZXh0U3ViRmxvd01hcHBpbmc6IGRlc2NlbmRpbmcgdG8gSFQtQyB3aXRoIGhvcHM9e2hvcHMgLSAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-b-subflow-done", + "target": "ht-b-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b: subflow finished" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-b-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-b-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-b-approve", + "target": "ht-b-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-b-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-b-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-b-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-b cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/ht-c.json b/core/Workflows/human-task-chain/ht-c.json new file mode 100644 index 0000000..7f4d99e --- /dev/null +++ b/core/Workflows/human-task-chain/ht-c.json @@ -0,0 +1,254 @@ +{ + "key": "ht-c", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "core" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-C (core)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-c", + "target": "ht-c-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-c" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-c-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-c", + "target": "ht-c-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-c" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-c-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-c-descend", + "target": "ht-c-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c: descend to ht-d" + } + ], + "rule": { + "location": "./src/DescendRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBEZXNjZW5kIHdoaWxlIGBob3BzYCBpcyBwb3NpdGl2ZS4gRXZlcnkgc3ViZmxvdyBtYXBwaW5nIGRlY3JlbWVudHMgaXQsIHNvIHRoZSBwYXlsb2FkJ3MgaW5pdGlhbAovLy8gdmFsdWUgYWxvbmUgZGVjaWRlcyB3aGljaCBsZXZlbCBlbmRzIHVwIGhvbGRpbmcgdGhlIGh1bWFuIHRhc2sg4oCUIHRoZSBkZWZpbml0aW9uIGNoYWluIGlzIHRoZQovLy8gc2FtZSBmb3IgYWxsIHRocmVlIHNjZW5hcmlvcy4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIERlc2NlbmRSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJob3BzIiwgb3V0IHZhciByYXcpICYmIHJhdyAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgaW50LlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgaG9wcyk7CiAgICAgICAgfQoKICAgICAgICB2YXIgZGVzY2VuZCA9IGhvcHMgPiAwOwogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiRGVzY2VuZFJ1bGU6IGhvcHM9e2hvcHN9IGRlc2NlbmQ9e2Rlc2NlbmR9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChkZXNjZW5kKTsKICAgIH0KfQo=" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-c-to-human", + "target": "ht-c-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-c-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c subflow" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "ht-d", + "domain": "partner", + "version": "1.0.6", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/HtcToNextSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gSGFuZHMgdGhlIGNoYWluIGRvd24gb25lIGxldmVsOiBkZWNyZW1lbnRzIGBob3BzYCwgY2FycmllcyBgdGVzdElkYCwgYW5kIGdpdmVzIHRoZSBjaGlsZCBpdHMgT1dOCi8vLyBodW1hblRhc2sgdGV4dC4gVGhlIGRpc3RpbmN0IHRleHQgcGVyIGxldmVsIGlzIHRoZSBwb2ludCDigJQgYSB0ZXN0IGNhbiB0aGVuIHRlbGwgd2hldGhlciB0aGUKLy8vIGxpc3RlZCB0aXRsZSBjYW1lIGZyb20gdGhlIGxlYWYgb3Igd2FzIHRha2VuIGZyb20gYW4gYW5jZXN0b3IuCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBIdGNUb05leHRTdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBob3BzID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoImhvcHMiLCBvdXQgdmFyIHJhdykgJiYgcmF3ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBpbnQuVHJ5UGFyc2UocmF3LlRvU3RyaW5nKCksIG91dCBob3BzKTsKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgY2hpbGRJbnB1dC5ob3BzID0gaG9wcyAtIDE7CgogICAgICAgIGlmIChkYXRhICE9IG51bGwgJiYgZGF0YS5UcnlHZXRWYWx1ZSgidGVzdElkIiwgb3V0IHZhciB0ZXN0SWQpICYmIHRlc3RJZCAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgY2hpbGRJbnB1dC50ZXN0SWQgPSB0ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBkeW5hbWljIGh1bWFuVGFzayA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaHVtYW5UYXNrLnRpdGxlID0gIkhULUQgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUQgc3RlcCBkZXNjcmlwdGlvbiI7CiAgICAgICAgY2hpbGRJbnB1dC5odW1hblRhc2sgPSBodW1hblRhc2s7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiSHRjVG9OZXh0U3ViRmxvd01hcHBpbmc6IGRlc2NlbmRpbmcgdG8gSFQtRCB3aXRoIGhvcHM9e2hvcHMgLSAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-c-subflow-done", + "target": "ht-c-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c: subflow finished" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-c-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-c-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-c-approve", + "target": "ht-c-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-c-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-c-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-c-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-c cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} \ No newline at end of file diff --git a/core/Workflows/human-task-chain/src/DescendRule.csx b/core/Workflows/human-task-chain/src/DescendRule.csx new file mode 100644 index 0000000..5246b4a --- /dev/null +++ b/core/Workflows/human-task-chain/src/DescendRule.csx @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Descend while `hops` is positive. Every subflow mapping decrements it, so the payload's initial +/// value alone decides which level ends up holding the human task — the definition chain is the +/// same for all three scenarios. +/// +public class DescendRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + var descend = hops > 0; + LogInformation($"DescendRule: hops={hops} descend={descend}"); + return Task.FromResult(descend); + } +} diff --git a/core/Workflows/human-task-chain/src/HtaToNextSubFlowMapping.csx b/core/Workflows/human-task-chain/src/HtaToNextSubFlowMapping.csx new file mode 100644 index 0000000..ceff271 --- /dev/null +++ b/core/Workflows/human-task-chain/src/HtaToNextSubFlowMapping.csx @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class HtaToNextSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-B step"; + humanTask.description = "HT-B step description"; + childInput.humanTask = humanTask; + + LogInformation($"HtaToNextSubFlowMapping: descending to HT-B with hops={hops - 1}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/core/Workflows/human-task-chain/src/HtbToNextSubFlowMapping.csx b/core/Workflows/human-task-chain/src/HtbToNextSubFlowMapping.csx new file mode 100644 index 0000000..d3a6f0a --- /dev/null +++ b/core/Workflows/human-task-chain/src/HtbToNextSubFlowMapping.csx @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class HtbToNextSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-C step"; + humanTask.description = "HT-C step description"; + childInput.humanTask = humanTask; + + LogInformation($"HtbToNextSubFlowMapping: descending to HT-C with hops={hops - 1}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/core/Workflows/human-task-chain/src/HtcToNextSubFlowMapping.csx b/core/Workflows/human-task-chain/src/HtcToNextSubFlowMapping.csx new file mode 100644 index 0000000..77c0c90 --- /dev/null +++ b/core/Workflows/human-task-chain/src/HtcToNextSubFlowMapping.csx @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class HtcToNextSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-D step"; + humanTask.description = "HT-D step description"; + childInput.humanTask = humanTask; + + LogInformation($"HtcToNextSubFlowMapping: descending to HT-D with hops={hops - 1}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/core/Workflows/human-task-chain/src/SpawnProcessMapping.csx b/core/Workflows/human-task-chain/src/SpawnProcessMapping.csx new file mode 100644 index 0000000..a9c5335 --- /dev/null +++ b/core/Workflows/human-task-chain/src/SpawnProcessMapping.csx @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Definitions; +using BBT.Workflow.Scripting; + +/// +/// Task 14 (SubProcess) mapping for ht-a-spawn-process -> partner/ht-d, fire-and-forget over Dapr +/// service invocation. It gets its OWN hop budget, because it is not a continuation of the root's +/// chain — it is a separate unit of work that may itself descend through SubFlows. +/// +/// +/// A state can no longer start a SubProcess directly (state.subFlow.type: "P" is rejected at +/// publish by the runtime's validator — a state starts a SubFlow and only a SubFlow). SubProcess +/// start is now expressed as this SubProcessTask (type "14") wired on the transition's +/// onExecutionTasks, which is why this mapping implements IMapping and casts its task +/// to SubProcessTask instead of implementing ISubProcessMapping against a +/// subFlow.process block. +/// +public class SpawnProcessMapping : ScriptBase, IMapping +{ + public Task InputHandler(WorkflowTask task, ScriptContext context) + { + var sub = task as SubProcessTask ?? throw new InvalidOperationException("Task must be a SubProcessTask"); + var data = context.Instance?.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("processHops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic body = new ExpandoObject(); + body.hops = hops; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + body.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-D process step"; + humanTask.description = "HT-D process step description"; + body.humanTask = humanTask; + + sub.SetDomain("partner"); + sub.SetFlow("ht-d"); + sub.SetVersion("1.0.6"); + sub.SetUseDapr(true); + sub.SetSync(false); + sub.SetBody(body); + + LogInformation($"SpawnProcessMapping: spawning ht-d as SubProcess with hops={hops}"); + return Task.FromResult(new ScriptResponse()); + } + + public Task OutputHandler(ScriptContext context) + { + // Fire-and-forget: the SubProcess is an independent unit of work and nothing projects its + // state upward, so there is nothing to merge back into ht-a's own data. + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/core/Workflows/human-task-chain/src/SpawnProcessRule.csx b/core/Workflows/human-task-chain/src/SpawnProcessRule.csx new file mode 100644 index 0000000..580794f --- /dev/null +++ b/core/Workflows/human-task-chain/src/SpawnProcessRule.csx @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Fires the root's SubProcess branch when the payload asks for it (mode = "process"). +/// A SubProcess is fire-and-forget: the root does not wait for it and nothing projects its state +/// upward, so the two become independent units of work and the list must carry BOTH. +/// +public class SpawnProcessRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var spawn = data != null + && data.TryGetValue("mode", out var mode) + && mode != null + && mode.ToString() == "process"; + + LogInformation($"SpawnProcessRule: spawn={spawn}"); + return Task.FromResult(spawn); + } +} diff --git a/core/Workflows/script-perf-lab/script-perf-lab.json b/core/Workflows/script-perf-lab/script-perf-lab.json index 930f908..aa9aac3 100644 --- a/core/Workflows/script-perf-lab/script-perf-lab.json +++ b/core/Workflows/script-perf-lab/script-perf-lab.json @@ -673,4 +673,4 @@ ] } } -} +} \ No newline at end of file diff --git a/core/Workflows/script-race-lab/script-race-lab-child.json b/core/Workflows/script-race-lab/script-race-lab-child.json index 604f947..8e4f538 100644 --- a/core/Workflows/script-race-lab/script-race-lab-child.json +++ b/core/Workflows/script-race-lab/script-race-lab-child.json @@ -118,4 +118,4 @@ } ] } -} +} \ No newline at end of file diff --git a/core/Workflows/script-race-lab/script-race-lab-parent.json b/core/Workflows/script-race-lab/script-race-lab-parent.json index 745678b..d60b984 100644 --- a/core/Workflows/script-race-lab/script-race-lab-parent.json +++ b/core/Workflows/script-race-lab/script-race-lab-parent.json @@ -175,4 +175,4 @@ ] } } -} +} \ No newline at end of file diff --git a/core/Workflows/subflow-orchestration/subflow-orchestration-child.json b/core/Workflows/subflow-orchestration/subflow-orchestration-child.json index 61991da..9332947 100644 --- a/core/Workflows/subflow-orchestration/subflow-orchestration-child.json +++ b/core/Workflows/subflow-orchestration/subflow-orchestration-child.json @@ -321,4 +321,4 @@ } ] } -} +} \ No newline at end of file diff --git a/core/Workflows/subflow-orchestration/subflow-orchestration-parent.json b/core/Workflows/subflow-orchestration/subflow-orchestration-parent.json index 44f01e7..4d08a21 100644 --- a/core/Workflows/subflow-orchestration/subflow-orchestration-parent.json +++ b/core/Workflows/subflow-orchestration/subflow-orchestration-parent.json @@ -314,4 +314,4 @@ ] } } -} +} \ No newline at end of file diff --git a/core/Workflows/subflow-start-failure-lab/build-subflow-start-failure-lab.py b/core/Workflows/subflow-start-failure-lab/build-subflow-start-failure-lab.py new file mode 100644 index 0000000..a7ed098 --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/build-subflow-start-failure-lab.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Regenerates subflow-start-failure-lab-parent.json from the .csx sources in ./src. + +The workflow JSON embeds every mapping / rule as base64 in `code` next to its `location`; +edit the .csx files and re-run this script — never hand-edit the base64 blobs. + + python3 core/Workflows/subflow-start-failure-lab/build-subflow-start-failure-lab.py +""" + +import base64 +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def code(name): + raw = (ROOT / "src" / name).read_bytes() + return { + "location": f"./src/{name}", + "code": base64.b64encode(raw).decode(), + } + + +def script_task(): + return {"key": "subflow-script-task", "domain": "core", "version": "1.0.0", "flow": "sys-tasks"} + + +def label(text): + return [{"language": "en-US", "label": text}] + + +workflow = { + "key": "subflow-start-failure-lab-parent", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "subflow-start-failure-lab", + "parent", + "subflow", + "post-commit-fault", + "retry", + ], + "attributes": { + "type": "F", + "timeout": None, + "labels": label("SubFlow Start Failure Lab Parent"), + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-subflow-start-failure-lab-parent", + "target": "parent-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start Parent"), + "onExecutionTasks": [ + {"order": 1, "task": script_task(), "mapping": code("ParentStartMapping.csx")} + ], + }, + "states": [ + { + "key": "parent-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": label("Parent Initial"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "auto-parent-to-subflow", + "target": "parent-subflow-state", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": label("Auto to Parent SubFlow State"), + "rule": code("AlwaysTrueRule.csx"), + "onExecutionTasks": [], + } + ], + }, + { + # stateType 4 = SubFlow. subFlow.type MUST be "S" here: a state-level "P" (SubProcess) + # is rejected at publish (WorkflowValidator, ~line 386) — use a SubProcess TASK instead + # for that case. This lab is specifically about the "S" post-commit-fault path. + # + # FORM TRIED FIRST (cheaper): subFlow.process pointing at a real child key + # (subflow-orchestration-child) but a version that was never published ("9.9.9"). + # `wf sync` ACCEPTED that at publish time — no reference-consistency check walks + # into subFlow.process versions. But starting the PARENT then failed synchronously, + # BEFORE any instance/correlation existed at all: IComponentCacheStore resolves the + # whole reachable component graph (including every subFlow.process) when the + # parent's own definition is loaded, so the 404 on "9.9.9" surfaced as a plain + # StartInstance 404 on the PARENT itself (error target + # "core/subflow-orchestration-child@9.9.9", Cache:300001) — never reaching + # HandleSubFlowStep, so no InstanceCorrelation was ever committed. That does not + # reproduce the bug under test (a correlation committed BEFORE the child-start + # failure), so this lab uses the schema fallback below instead. + "key": "parent-subflow-state", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": label("Parent SubFlow State (child start fails schema validation)"), + "view": None, + "subFlow": { + "type": "S", + "process": { + # A REAL, resolvable child (own component, own published version) so the + # component-cache graph load for the PARENT succeeds and the parent reaches + # parent-subflow-state normally. HandleSubFlowStep (order 70) commits the + # InstanceCorrelation in its own UoW; only THEN does the post-commit + # StartSubflowJob call subflow-start-failure-lab-child's start transition, + # whose schema requires "mustProvide" — a field + # ParentToChildSubFlowMapping.csx deliberately never supplies. That failure + # is classified Validation ("client error") by DefaultPostCommitFailurePolicy, + # but TransitionRunner.CompensateFailedCoordinationAsync faults the parent + # unconditionally for a failed StartSubflowJob regardless of that + # classification (see its "E31" comment) — so the fault path under test + # fires from a genuinely committed-correlation state. + "key": "subflow-start-failure-lab-child", + "domain": "core", + "version": "1.0.0", + "flow": "sys-flows", + }, + "mapping": code("ParentToChildSubFlowMapping.csx"), + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + # Unreachable in this fixture (HandleSubFlowStep skips straight to Finalize for + # subFlow.type "S"), kept only so the workflow has a nameable path to completion + # if the reference were ever made resolvable. + "key": "auto-parent-to-completed", + "target": "parent-completed", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": label("Auto to Parent Completed"), + "rule": code("AlwaysTrueRule.csx"), + "onExecutionTasks": [], + } + ], + }, + { + "key": "parent-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": label("Parent Completed"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [], + }, + ], + }, +} + +out = ROOT / "subflow-start-failure-lab-parent.json" +out.write_text(json.dumps(workflow, indent=2) + "\n") +print(f"wrote {out}") diff --git a/core/Workflows/subflow-start-failure-lab/src/AlwaysTrueRule.csx b/core/Workflows/subflow-start-failure-lab/src/AlwaysTrueRule.csx new file mode 100644 index 0000000..3d08a0f --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/src/AlwaysTrueRule.csx @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +public class AlwaysTrueRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + LogInformation("AlwaysTrueRule: returning true"); + return Task.FromResult(true); + } +} diff --git a/core/Workflows/subflow-start-failure-lab/src/ParentStartMapping.csx b/core/Workflows/subflow-start-failure-lab/src/ParentStartMapping.csx new file mode 100644 index 0000000..db0a9f4 --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/src/ParentStartMapping.csx @@ -0,0 +1,26 @@ +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Definitions; +using BBT.Workflow.Scripting; + +public class ParentStartMapping : ScriptBase, IMapping +{ + public Task InputHandler(WorkflowTask task, ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } + + public Task OutputHandler(ScriptContext context) + { + var data = context.Instance.Data; + dynamic result = new ExpandoObject(); + result.parentStarted = true; + if (data != null && HasProperty(data, "testId")) + { + result.testId = data.testId; + } + + LogInformation("ParentStartMapping: parentStarted set"); + return Task.FromResult(new ScriptResponse { Data = result }); + } +} diff --git a/core/Workflows/subflow-start-failure-lab/src/ParentToChildSubFlowMapping.csx b/core/Workflows/subflow-start-failure-lab/src/ParentToChildSubFlowMapping.csx new file mode 100644 index 0000000..9f15beb --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/src/ParentToChildSubFlowMapping.csx @@ -0,0 +1,35 @@ +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Input mapping for the parent-subflow-state → child SubFlow start. +/// +/// Deliberately never supplies mustProvide, the field +/// subflow-start-failure-lab-child-start's schema requires. HandleSubFlowStep (order 70) +/// commits the parent's InstanceCorrelation in its own unit of work before the post-commit +/// StartSubflowJob ever calls the child's start transition, so by the time that transition fails +/// schema validation, the correlation is already durable and the child was never created. +/// +/// +public class ParentToChildSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data; + dynamic childInput = new ExpandoObject(); + if (data != null && HasProperty(data, "testId")) + { + childInput.testId = data.testId; + } + // "mustProvide" is intentionally NOT set here — see class summary. + LogInformation("ParentToChildSubFlowMapping: prepared child input (mustProvide omitted on purpose)"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + // Never invoked: the child never starts, so the subflow correlation never completes. + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-child.json b/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-child.json new file mode 100644 index 0000000..20c8349 --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-child.json @@ -0,0 +1,63 @@ +{ + "key": "subflow-start-failure-lab-child", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.0", + "tags": [ + "integration-test", + "subflow-start-failure-lab", + "child", + "subflow" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "SubFlow Start Failure Lab Child" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-subflow-start-failure-lab-child", + "target": "child-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Child" + } + ], + "schema": { + "key": "subflow-start-failure-lab-child-start", + "domain": "core", + "flow": "sys-schemas", + "version": "1.0.0" + } + }, + "states": [ + { + "key": "child-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Child Initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} diff --git a/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-parent.json b/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-parent.json new file mode 100644 index 0000000..62eb3c8 --- /dev/null +++ b/core/Workflows/subflow-start-failure-lab/subflow-start-failure-lab-parent.json @@ -0,0 +1,156 @@ +{ + "key": "subflow-start-failure-lab-parent", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "subflow-start-failure-lab", + "parent", + "subflow", + "post-commit-fault", + "retry" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "SubFlow Start Failure Lab Parent" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-subflow-start-failure-lab-parent", + "target": "parent-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Parent" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "subflow-script-task", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/ParentStartMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCnB1YmxpYyBjbGFzcyBQYXJlbnRTdGFydE1hcHBpbmcgOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBkYXRhID0gY29udGV4dC5JbnN0YW5jZS5EYXRhOwogICAgICAgIGR5bmFtaWMgcmVzdWx0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICByZXN1bHQucGFyZW50U3RhcnRlZCA9IHRydWU7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBIYXNQcm9wZXJ0eShkYXRhLCAidGVzdElkIikpCiAgICAgICAgewogICAgICAgICAgICByZXN1bHQudGVzdElkID0gZGF0YS50ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBMb2dJbmZvcm1hdGlvbigiUGFyZW50U3RhcnRNYXBwaW5nOiBwYXJlbnRTdGFydGVkIHNldCIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IHJlc3VsdCB9KTsKICAgIH0KfQo=" + } + } + ] + }, + "states": [ + { + "key": "parent-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Parent Initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "auto-parent-to-subflow", + "target": "parent-subflow-state", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto to Parent SubFlow State" + } + ], + "rule": { + "location": "./src/AlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCnB1YmxpYyBjbGFzcyBBbHdheXNUcnVlUnVsZSA6IFNjcmlwdEJhc2UsIElDb25kaXRpb25NYXBwaW5nCnsKICAgIHB1YmxpYyBUYXNrPGJvb2w+IEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIExvZ0luZm9ybWF0aW9uKCJBbHdheXNUcnVlUnVsZTogcmV0dXJuaW5nIHRydWUiKTsKICAgICAgICByZXR1cm4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwogICAgfQp9Cg==" + }, + "onExecutionTasks": [] + } + ] + }, + { + "key": "parent-subflow-state", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Parent SubFlow State (child start fails schema validation)" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "subflow-start-failure-lab-child", + "domain": "core", + "version": "1.0.0", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/ParentToChildSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBJbnB1dCBtYXBwaW5nIGZvciB0aGUgcGFyZW50LXN1YmZsb3ctc3RhdGUg4oaSIGNoaWxkIFN1YkZsb3cgc3RhcnQuCi8vLyA8cGFyYT4KLy8vIERlbGliZXJhdGVseSBuZXZlciBzdXBwbGllcyA8Yz5tdXN0UHJvdmlkZTwvYz4sIHRoZSBmaWVsZAovLy8gPGM+c3ViZmxvdy1zdGFydC1mYWlsdXJlLWxhYi1jaGlsZC1zdGFydDwvYz4ncyBzY2hlbWEgcmVxdWlyZXMuIEhhbmRsZVN1YkZsb3dTdGVwIChvcmRlciA3MCkKLy8vIGNvbW1pdHMgdGhlIHBhcmVudCdzIEluc3RhbmNlQ29ycmVsYXRpb24gaW4gaXRzIG93biB1bml0IG9mIHdvcmsgYmVmb3JlIHRoZSBwb3N0LWNvbW1pdAovLy8gU3RhcnRTdWJmbG93Sm9iIGV2ZXIgY2FsbHMgdGhlIGNoaWxkJ3Mgc3RhcnQgdHJhbnNpdGlvbiwgc28gYnkgdGhlIHRpbWUgdGhhdCB0cmFuc2l0aW9uIGZhaWxzCi8vLyBzY2hlbWEgdmFsaWRhdGlvbiwgdGhlIGNvcnJlbGF0aW9uIGlzIGFscmVhZHkgZHVyYWJsZSBhbmQgdGhlIGNoaWxkIHdhcyBuZXZlciBjcmVhdGVkLgovLy8gPC9wYXJhPgovLy8gPC9zdW1tYXJ5PgpwdWJsaWMgY2xhc3MgUGFyZW50VG9DaGlsZFN1YkZsb3dNYXBwaW5nIDogU2NyaXB0QmFzZSwgSVN1YkZsb3dNYXBwaW5nCnsKICAgIHB1YmxpYyBUYXNrPFNjcmlwdFJlc3BvbnNlPiBJbnB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBkYXRhID0gY29udGV4dC5JbnN0YW5jZS5EYXRhOwogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBIYXNQcm9wZXJ0eShkYXRhLCAidGVzdElkIikpCiAgICAgICAgewogICAgICAgICAgICBjaGlsZElucHV0LnRlc3RJZCA9IGRhdGEudGVzdElkOwogICAgICAgIH0KICAgICAgICAvLyAibXVzdFByb3ZpZGUiIGlzIGludGVudGlvbmFsbHkgTk9UIHNldCBoZXJlIOKAlCBzZWUgY2xhc3Mgc3VtbWFyeS4KICAgICAgICBMb2dJbmZvcm1hdGlvbigiUGFyZW50VG9DaGlsZFN1YkZsb3dNYXBwaW5nOiBwcmVwYXJlZCBjaGlsZCBpbnB1dCAobXVzdFByb3ZpZGUgb21pdHRlZCBvbiBwdXJwb3NlKSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIC8vIE5ldmVyIGludm9rZWQ6IHRoZSBjaGlsZCBuZXZlciBzdGFydHMsIHNvIHRoZSBzdWJmbG93IGNvcnJlbGF0aW9uIG5ldmVyIGNvbXBsZXRlcy4KICAgICAgICByZXR1cm4gVGFzay5Gcm9tUmVzdWx0KG5ldyBTY3JpcHRSZXNwb25zZSgpKTsKICAgIH0KfQo=" + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "auto-parent-to-completed", + "target": "parent-completed", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto to Parent Completed" + } + ], + "rule": { + "location": "./src/AlwaysTrueRule.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCnB1YmxpYyBjbGFzcyBBbHdheXNUcnVlUnVsZSA6IFNjcmlwdEJhc2UsIElDb25kaXRpb25NYXBwaW5nCnsKICAgIHB1YmxpYyBUYXNrPGJvb2w+IEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIExvZ0luZm9ybWF0aW9uKCJBbHdheXNUcnVlUnVsZTogcmV0dXJuaW5nIHRydWUiKTsKICAgICAgICByZXR1cm4gVGFzay5Gcm9tUmVzdWx0KHRydWUpOwogICAgfQp9Cg==" + }, + "onExecutionTasks": [] + } + ] + }, + { + "key": "parent-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Parent Completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} diff --git a/core/Workflows/task-invocation-lab/src/TilResultProjection.csx b/core/Workflows/task-invocation-lab/src/TilResultProjection.csx new file mode 100644 index 0000000..5e83635 --- /dev/null +++ b/core/Workflows/task-invocation-lab/src/TilResultProjection.csx @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Definitions; +using BBT.Workflow.Scripting; + +/// +/// Shared mapping for every case in the task-invocation-lab scenario (issue #1007: Http, DaprService, +/// Soap, StateStore and CacheAside all run in-process on Orchestration now, behind a routing config +/// that can flip each type back to the Execution service). +/// +/// InputHandler — the only per-invocation input work any case needs: resolve the +/// API_BASEURL placeholder on an or 's URL from +/// configuration, exactly like EbHttpMapping in error-boundary-lab. Every other task type in +/// this lab (DaprService, StateStore, CacheAside) has no URL to resolve, so the handler is a no-op +/// for them. +/// +/// +/// OutputHandler — writes ONE fixed-shape projection of the just-completed task's +/// TaskInvocationResult (already merged onto context.Body as a +/// StandardTaskResponse by the executor before this handler runs) into instance data: +/// tilCase, tilTaskType, tilStatusCode, tilHasData, tilData, +/// tilBodyLength, tilMetadataKeys, tilMetadata, tilAt. Deliberately +/// generic — no per-case branching — so the SAME projection lets a test read the state-store +/// round-trip value (tilData, written by til-statestore-get) and the cache-aside hit +/// flag (tilMetadata.CacheHit, written by til-cacheaside) without this file knowing +/// which case is running. tilCase comes from context.Transition.Key so a test can tell +/// the cases apart in instance data alone. +/// +/// +/// tilTaskType is intentionally NOT normalized (kept exactly as +/// StandardTaskResponse.TaskType stamps it). That field is the parity trap this whole scenario +/// exists to catch — see the build plan note that it was mis-stamped twice on this branch depending +/// on routing. Normalizing it here would hide a regression instead of surfacing it. +/// +/// +/// CacheAside is the one exception to how this file gets attached. +/// CacheAsideTaskExecutor.ProcessOutputAsync does not call the transition-level +/// onExecutionTasks[].mapping's OutputHandler at all — it reads the task config's own +/// sourceMapping field instead. til-cacheaside.json therefore points BOTH its +/// transition-level mapping (harmless no-op InputHandler here) AND its task-level +/// config.sourceMapping at this same file, so the projection still runs for that case. +/// +/// +public class TilResultProjection : ScriptBase, IMapping +{ + public Task InputHandler(WorkflowTask task, ScriptContext context) + { + var apiBaseUrl = GetConfigValue("Example:ApiBaseUrl", "http://localhost:3001"); + + if (task is HttpTask httpTask) + { + httpTask.SetUrl(httpTask.Url.Replace("API_BASEURL", apiBaseUrl)); + } + else if (task is SoapTask soapTask) + { + soapTask.SetUrl(soapTask.Url.Replace("API_BASEURL", apiBaseUrl)); + } + + return Task.FromResult(new ScriptResponse()); + } + + public Task OutputHandler(ScriptContext context) + { + var body = context.Body as IDictionary; + + var caseKey = context.Transition?.Key ?? "unknown"; + var taskType = ReadString(body, "taskType"); + var statusCode = ReadNullableLong(body, "statusCode"); + var data = ReadValue(body, "data"); + var rawBody = ReadString(body, "body"); + var metadata = ReadValue(body, "metadata") as IDictionary; + + var metadataKeys = new List(); + if (metadata != null) + { + foreach (var key in metadata.Keys) + { + metadataKeys.Add(key); + } + } + + dynamic result = new ExpandoObject(); + var target = (IDictionary)result; + // Delta-only: the write service merges this into the DB head under the per-instance lock. + target["tilCase"] = caseKey; + target["tilTaskType"] = taskType ?? string.Empty; + target["tilStatusCode"] = statusCode.HasValue ? (object)statusCode.Value : null; + target["tilHasData"] = data != null; + target["tilData"] = data; + target["tilBodyLength"] = rawBody?.Length ?? 0; + target["tilMetadataKeys"] = metadataKeys; + target["tilMetadata"] = metadata; + target["tilAt"] = DateTime.UtcNow.ToString("o"); + + LogInformation( + $"TilResultProjection: case={caseKey} taskType={taskType} statusCode={statusCode} hasData={data != null}"); + return Task.FromResult(new ScriptResponse { Data = result }); + } + + private static string? ReadString(IDictionary? body, string key) + { + if (body == null || !body.TryGetValue(key, out var raw) || raw == null) + { + return null; + } + + return raw.ToString(); + } + + private static long? ReadNullableLong(IDictionary? body, string key) + { + if (body == null || !body.TryGetValue(key, out var raw) || raw == null) + { + return null; + } + + return raw switch + { + long l => l, + int i => i, + double d => (long)d, + _ => long.TryParse(raw.ToString(), out var parsed) ? parsed : (long?)null + }; + } + + private static object? ReadValue(IDictionary? body, string key) + { + if (body == null || !body.TryGetValue(key, out var raw)) + { + return null; + } + + return raw; + } +} diff --git a/core/Workflows/task-invocation-lab/task-invocation-lab.json b/core/Workflows/task-invocation-lab/task-invocation-lab.json new file mode 100644 index 0000000..0666118 --- /dev/null +++ b/core/Workflows/task-invocation-lab/task-invocation-lab.json @@ -0,0 +1,447 @@ +{ + "key": "task-invocation-lab", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.6", + "tags": [ + "integration-test", + "task-invocation-lab", + "http", + "dapr", + "soap", + "statestore", + "cacheaside", + "issue-1007" + ], + "attributes": { + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Task Invocation Lab" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-task-invocation-lab", + "target": "ready", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Task Invocation Lab" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ready", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Ready (pick a case)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "case-http-ok", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Http (type 6): 200 success" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-http-ok", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "case-http-500", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Http (type 6): business error -> task boundary Notify" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-http-500", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + }, + "errorBoundary": { + "onError": [ + { + "action": 4, + "errorCodes": [ + "500" + ], + "transition": "to-notified", + "priority": 100 + } + ] + } + } + ] + }, + { + "key": "case-http-slow", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Http (type 6): client timeoutSeconds vs a slow MockLab route -> task boundary Rollback" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-http-slow", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + }, + "errorBoundary": { + "onError": [ + { + "action": 2, + "transition": "to-rolled-back", + "priority": 100, + "errorTypes": [ + "TaskCanceledException" + ] + } + ] + } + } + ] + }, + { + "key": "case-dapr-ok", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "DaprService (type 3): success via Dapr service invocation to the mocklab app-id" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-dapr-ok", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "case-soap-ok", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Soap (type 16): SOAP 1.1 success, XML parsed" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-soap-ok", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "case-soap-fault", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Soap (type 16): SOAP fault over HTTP 500 -> task boundary Abort (instance faults)" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-soap-fault", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + }, + "errorBoundary": { + "onError": [ + { + "action": 0, + "errorCodes": [ + "500" + ], + "priority": 100 + } + ] + } + } + ] + }, + { + "key": "case-statestore-set", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "StateStore (type 17): command=set with a ttl" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-statestore-set", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "case-statestore-get", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "StateStore (type 17): command=get reads what case-statestore-set wrote" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-statestore-get", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "case-cacheaside", + "target": "landed", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "CacheAside (type 18): read-through over til-cache-source; run twice (separate instances) for miss-then-hit" + } + ], + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "til-cacheaside", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/TilResultProjection.csx", + "code": "dXNpbmcgU3lzdGVtOwp1c2luZyBTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYzsKdXNpbmcgU3lzdGVtLkR5bmFtaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5EZWZpbml0aW9uczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFNoYXJlZCBtYXBwaW5nIGZvciBldmVyeSBjYXNlIGluIHRoZSB0YXNrLWludm9jYXRpb24tbGFiIHNjZW5hcmlvIChpc3N1ZSAjMTAwNzogSHR0cCwgRGFwclNlcnZpY2UsCi8vLyBTb2FwLCBTdGF0ZVN0b3JlIGFuZCBDYWNoZUFzaWRlIGFsbCBydW4gaW4tcHJvY2VzcyBvbiBPcmNoZXN0cmF0aW9uIG5vdywgYmVoaW5kIGEgcm91dGluZyBjb25maWcKLy8vIHRoYXQgY2FuIGZsaXAgZWFjaCB0eXBlIGJhY2sgdG8gdGhlIEV4ZWN1dGlvbiBzZXJ2aWNlKS4KLy8vIDxwYXJhPgovLy8gPGI+SW5wdXRIYW5kbGVyPC9iPiDigJQgdGhlIG9ubHkgcGVyLWludm9jYXRpb24gaW5wdXQgd29yayBhbnkgY2FzZSBuZWVkczogcmVzb2x2ZSB0aGUKLy8vIDxjPkFQSV9CQVNFVVJMPC9jPiBwbGFjZWhvbGRlciBvbiBhbiA8c2VlIGNyZWY9Ikh0dHBUYXNrIi8+IG9yIDxzZWUgY3JlZj0iU29hcFRhc2siLz4ncyBVUkwgZnJvbQovLy8gY29uZmlndXJhdGlvbiwgZXhhY3RseSBsaWtlIDxjPkViSHR0cE1hcHBpbmc8L2M+IGluIGVycm9yLWJvdW5kYXJ5LWxhYi4gRXZlcnkgb3RoZXIgdGFzayB0eXBlIGluCi8vLyB0aGlzIGxhYiAoRGFwclNlcnZpY2UsIFN0YXRlU3RvcmUsIENhY2hlQXNpZGUpIGhhcyBubyBVUkwgdG8gcmVzb2x2ZSwgc28gdGhlIGhhbmRsZXIgaXMgYSBuby1vcAovLy8gZm9yIHRoZW0uCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxiPk91dHB1dEhhbmRsZXI8L2I+IOKAlCB3cml0ZXMgT05FIGZpeGVkLXNoYXBlIHByb2plY3Rpb24gb2YgdGhlIGp1c3QtY29tcGxldGVkIHRhc2sncwovLy8gPGM+VGFza0ludm9jYXRpb25SZXN1bHQ8L2M+IChhbHJlYWR5IG1lcmdlZCBvbnRvIDxjPmNvbnRleHQuQm9keTwvYz4gYXMgYQovLy8gPGM+U3RhbmRhcmRUYXNrUmVzcG9uc2U8L2M+IGJ5IHRoZSBleGVjdXRvciBiZWZvcmUgdGhpcyBoYW5kbGVyIHJ1bnMpIGludG8gaW5zdGFuY2UgZGF0YToKLy8vIDxjPnRpbENhc2U8L2M+LCA8Yz50aWxUYXNrVHlwZTwvYz4sIDxjPnRpbFN0YXR1c0NvZGU8L2M+LCA8Yz50aWxIYXNEYXRhPC9jPiwgPGM+dGlsRGF0YTwvYz4sCi8vLyA8Yz50aWxCb2R5TGVuZ3RoPC9jPiwgPGM+dGlsTWV0YWRhdGFLZXlzPC9jPiwgPGM+dGlsTWV0YWRhdGE8L2M+LCA8Yz50aWxBdDwvYz4uIERlbGliZXJhdGVseQovLy8gZ2VuZXJpYyDigJQgbm8gcGVyLWNhc2UgYnJhbmNoaW5nIOKAlCBzbyB0aGUgU0FNRSBwcm9qZWN0aW9uIGxldHMgYSB0ZXN0IHJlYWQgdGhlIHN0YXRlLXN0b3JlCi8vLyByb3VuZC10cmlwIHZhbHVlICg8Yz50aWxEYXRhPC9jPiwgd3JpdHRlbiBieSA8Yz50aWwtc3RhdGVzdG9yZS1nZXQ8L2M+KSBhbmQgdGhlIGNhY2hlLWFzaWRlIGhpdAovLy8gZmxhZyAoPGM+dGlsTWV0YWRhdGEuQ2FjaGVIaXQ8L2M+LCB3cml0dGVuIGJ5IDxjPnRpbC1jYWNoZWFzaWRlPC9jPikgd2l0aG91dCB0aGlzIGZpbGUga25vd2luZwovLy8gd2hpY2ggY2FzZSBpcyBydW5uaW5nLiA8Yz50aWxDYXNlPC9jPiBjb21lcyBmcm9tIDxjPmNvbnRleHQuVHJhbnNpdGlvbi5LZXk8L2M+IHNvIGEgdGVzdCBjYW4gdGVsbAovLy8gdGhlIGNhc2VzIGFwYXJ0IGluIGluc3RhbmNlIGRhdGEgYWxvbmUuCi8vLyA8L3BhcmE+Ci8vLyA8cGFyYT4KLy8vIDxjPnRpbFRhc2tUeXBlPC9jPiBpcyBpbnRlbnRpb25hbGx5IE5PVCBub3JtYWxpemVkIChrZXB0IGV4YWN0bHkgYXMKLy8vIDxjPlN0YW5kYXJkVGFza1Jlc3BvbnNlLlRhc2tUeXBlPC9jPiBzdGFtcHMgaXQpLiBUaGF0IGZpZWxkIGlzIHRoZSBwYXJpdHkgdHJhcCB0aGlzIHdob2xlIHNjZW5hcmlvCi8vLyBleGlzdHMgdG8gY2F0Y2gg4oCUIHNlZSB0aGUgYnVpbGQgcGxhbiBub3RlIHRoYXQgaXQgd2FzIG1pcy1zdGFtcGVkIHR3aWNlIG9uIHRoaXMgYnJhbmNoIGRlcGVuZGluZwovLy8gb24gcm91dGluZy4gTm9ybWFsaXppbmcgaXQgaGVyZSB3b3VsZCBoaWRlIGEgcmVncmVzc2lvbiBpbnN0ZWFkIG9mIHN1cmZhY2luZyBpdC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGI+Q2FjaGVBc2lkZSBpcyB0aGUgb25lIGV4Y2VwdGlvbiB0byBob3cgdGhpcyBmaWxlIGdldHMgYXR0YWNoZWQuPC9iPgovLy8gPGM+Q2FjaGVBc2lkZVRhc2tFeGVjdXRvci5Qcm9jZXNzT3V0cHV0QXN5bmM8L2M+IGRvZXMgbm90IGNhbGwgdGhlIHRyYW5zaXRpb24tbGV2ZWwKLy8vIDxjPm9uRXhlY3V0aW9uVGFza3NbXS5tYXBwaW5nPC9jPidzIDxjPk91dHB1dEhhbmRsZXI8L2M+IGF0IGFsbCDigJQgaXQgcmVhZHMgdGhlIHRhc2sgY29uZmlnJ3Mgb3duCi8vLyA8Yz5zb3VyY2VNYXBwaW5nPC9jPiBmaWVsZCBpbnN0ZWFkLiA8Yz50aWwtY2FjaGVhc2lkZS5qc29uPC9jPiB0aGVyZWZvcmUgcG9pbnRzIEJPVEggaXRzCi8vLyB0cmFuc2l0aW9uLWxldmVsIDxjPm1hcHBpbmc8L2M+IChoYXJtbGVzcyBuby1vcCBJbnB1dEhhbmRsZXIgaGVyZSkgQU5EIGl0cyB0YXNrLWxldmVsCi8vLyA8Yz5jb25maWcuc291cmNlTWFwcGluZzwvYz4gYXQgdGhpcyBzYW1lIGZpbGUsIHNvIHRoZSBwcm9qZWN0aW9uIHN0aWxsIHJ1bnMgZm9yIHRoYXQgY2FzZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFRpbFJlc3VsdFByb2plY3Rpb24gOiBTY3JpcHRCYXNlLCBJTWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFdvcmtmbG93VGFzayB0YXNrLCBTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGFwaUJhc2VVcmwgPSBHZXRDb25maWdWYWx1ZSgiRXhhbXBsZTpBcGlCYXNlVXJsIiwgImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIpOwoKICAgICAgICBpZiAodGFzayBpcyBIdHRwVGFzayBodHRwVGFzaykKICAgICAgICB7CiAgICAgICAgICAgIGh0dHBUYXNrLlNldFVybChodHRwVGFzay5VcmwuUmVwbGFjZSgiQVBJX0JBU0VVUkwiLCBhcGlCYXNlVXJsKSk7CiAgICAgICAgfQogICAgICAgIGVsc2UgaWYgKHRhc2sgaXMgU29hcFRhc2sgc29hcFRhc2spCiAgICAgICAgewogICAgICAgICAgICBzb2FwVGFzay5TZXRVcmwoc29hcFRhc2suVXJsLlJlcGxhY2UoIkFQSV9CQVNFVVJMIiwgYXBpQmFzZVVybCkpOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UoKSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHZhciBib2R5ID0gY29udGV4dC5Cb2R5IGFzIElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PjsKCiAgICAgICAgdmFyIGNhc2VLZXkgPSBjb250ZXh0LlRyYW5zaXRpb24/LktleSA/PyAidW5rbm93biI7CiAgICAgICAgdmFyIHRhc2tUeXBlID0gUmVhZFN0cmluZyhib2R5LCAidGFza1R5cGUiKTsKICAgICAgICB2YXIgc3RhdHVzQ29kZSA9IFJlYWROdWxsYWJsZUxvbmcoYm9keSwgInN0YXR1c0NvZGUiKTsKICAgICAgICB2YXIgZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAiZGF0YSIpOwogICAgICAgIHZhciByYXdCb2R5ID0gUmVhZFN0cmluZyhib2R5LCAiYm9keSIpOwogICAgICAgIHZhciBtZXRhZGF0YSA9IFJlYWRWYWx1ZShib2R5LCAibWV0YWRhdGEiKSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBtZXRhZGF0YUtleXMgPSBuZXcgTGlzdDxvYmplY3Q+KCk7CiAgICAgICAgaWYgKG1ldGFkYXRhICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga2V5IGluIG1ldGFkYXRhLktleXMpCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIG1ldGFkYXRhS2V5cy5BZGQoa2V5KTsKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgZHluYW1pYyByZXN1bHQgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIHZhciB0YXJnZXQgPSAoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+KXJlc3VsdDsKICAgICAgICAvLyBEZWx0YS1vbmx5OiB0aGUgd3JpdGUgc2VydmljZSBtZXJnZXMgdGhpcyBpbnRvIHRoZSBEQiBoZWFkIHVuZGVyIHRoZSBwZXItaW5zdGFuY2UgbG9jay4KICAgICAgICB0YXJnZXRbInRpbENhc2UiXSA9IGNhc2VLZXk7CiAgICAgICAgdGFyZ2V0WyJ0aWxUYXNrVHlwZSJdID0gdGFza1R5cGUgPz8gc3RyaW5nLkVtcHR5OwogICAgICAgIHRhcmdldFsidGlsU3RhdHVzQ29kZSJdID0gc3RhdHVzQ29kZS5IYXNWYWx1ZSA/IChvYmplY3Qpc3RhdHVzQ29kZS5WYWx1ZSA6IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxIYXNEYXRhIl0gPSBkYXRhICE9IG51bGw7CiAgICAgICAgdGFyZ2V0WyJ0aWxEYXRhIl0gPSBkYXRhOwogICAgICAgIHRhcmdldFsidGlsQm9keUxlbmd0aCJdID0gcmF3Qm9keT8uTGVuZ3RoID8/IDA7CiAgICAgICAgdGFyZ2V0WyJ0aWxNZXRhZGF0YUtleXMiXSA9IG1ldGFkYXRhS2V5czsKICAgICAgICB0YXJnZXRbInRpbE1ldGFkYXRhIl0gPSBtZXRhZGF0YTsKICAgICAgICB0YXJnZXRbInRpbEF0Il0gPSBEYXRlVGltZS5VdGNOb3cuVG9TdHJpbmcoIm8iKTsKCiAgICAgICAgTG9nSW5mb3JtYXRpb24oCiAgICAgICAgICAgICQiVGlsUmVzdWx0UHJvamVjdGlvbjogY2FzZT17Y2FzZUtleX0gdGFza1R5cGU9e3Rhc2tUeXBlfSBzdGF0dXNDb2RlPXtzdGF0dXNDb2RlfSBoYXNEYXRhPXtkYXRhICE9IG51bGx9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gcmVzdWx0IH0pOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIHN0cmluZz8gUmVhZFN0cmluZyhJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD4/IGJvZHksIHN0cmluZyBrZXkpCiAgICB7CiAgICAgICAgaWYgKGJvZHkgPT0gbnVsbCB8fCAhYm9keS5UcnlHZXRWYWx1ZShrZXksIG91dCB2YXIgcmF3KSB8fCByYXcgPT0gbnVsbCkKICAgICAgICB7CiAgICAgICAgICAgIHJldHVybiBudWxsOwogICAgICAgIH0KCiAgICAgICAgcmV0dXJuIHJhdy5Ub1N0cmluZygpOwogICAgfQoKICAgIHByaXZhdGUgc3RhdGljIGxvbmc/IFJlYWROdWxsYWJsZUxvbmcoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykgfHwgcmF3ID09IG51bGwpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXcgc3dpdGNoCiAgICAgICAgewogICAgICAgICAgICBsb25nIGwgPT4gbCwKICAgICAgICAgICAgaW50IGkgPT4gaSwKICAgICAgICAgICAgZG91YmxlIGQgPT4gKGxvbmcpZCwKICAgICAgICAgICAgXyA9PiBsb25nLlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgdmFyIHBhcnNlZCkgPyBwYXJzZWQgOiAobG9uZz8pbnVsbAogICAgICAgIH07CiAgICB9CgogICAgcHJpdmF0ZSBzdGF0aWMgb2JqZWN0PyBSZWFkVmFsdWUoSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+PyBib2R5LCBzdHJpbmcga2V5KQogICAgewogICAgICAgIGlmIChib2R5ID09IG51bGwgfHwgIWJvZHkuVHJ5R2V0VmFsdWUoa2V5LCBvdXQgdmFyIHJhdykpCiAgICAgICAgewogICAgICAgICAgICByZXR1cm4gbnVsbDsKICAgICAgICB9CgogICAgICAgIHJldHVybiByYXc7CiAgICB9Cn0K" + } + } + ] + }, + { + "key": "to-notified", + "target": "notified", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Boundary target: notified" + } + ], + "onExecutionTasks": [] + }, + { + "key": "to-rolled-back", + "target": "rolled-back", + "triggerType": 0, + "versionStrategy": "Patch", + "labels": [ + { + "language": "en-US", + "label": "Boundary target: rolled back" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "landed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Landed (the case ran to completion)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "notified", + "stateType": 3, + "subType": 2, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Notified by a task-level boundary" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "rolled-back", + "stateType": 3, + "subType": 2, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Rolled back by a task-level boundary" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "til-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ], + "cancel": { + "key": "cancel-task-invocation-lab", + "target": "til-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel Task Invocation Lab" + } + ] + } + } +} \ No newline at end of file diff --git a/core/Workflows/timeout-lab/src/TimeoutLabSubFlowMapping.csx b/core/Workflows/timeout-lab/src/TimeoutLabSubFlowMapping.csx new file mode 100644 index 0000000..f3ba19d --- /dev/null +++ b/core/Workflows/timeout-lab/src/TimeoutLabSubFlowMapping.csx @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Minimal SubFlow mapping for the timeout lab: the scenario is about the child's DEADLINE, not +/// about data, so nothing is mapped in either direction beyond a marker the assertions can read. +/// +/// +/// It exists because subFlow.mapping is required by the schema, not because the scenario +/// needs a transformation. Keeping it empty is deliberate — a mapping that moved real data would +/// give a failing test a second possible cause. +/// +public class TimeoutLabSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + dynamic childInput = new ExpandoObject(); + childInput.startedByTimeoutLabParent = true; + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + dynamic merged = new ExpandoObject(); + var target = (IDictionary)merged; + var inst = context.Instance.Data as IDictionary; + if (inst != null) + { + foreach (var kv in inst) + { + target[kv.Key] = kv.Value; + } + } + target["childSettled"] = true; + return Task.FromResult(new ScriptResponse { Data = merged }); + } +} diff --git a/core/Workflows/timeout-lab/timeout-lab-child.json b/core/Workflows/timeout-lab/timeout-lab-child.json new file mode 100644 index 0000000..00af663 --- /dev/null +++ b/core/Workflows/timeout-lab/timeout-lab-child.json @@ -0,0 +1,106 @@ +{ + "key": "timeout-lab-child", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.0", + "tags": [ + "integration-test", + "timeout-lab", + "subflow", + "timeout-override" + ], + "attributes": { + "_comment": "timeout is NULL on purpose, and that is the whole point of this fixture. The deadline this child runs under comes from its PARENT's subFlow.overrides.timeout. Until the effective-timeout resolver landed, that override was read only when the job was armed: the job fired on schedule, the handler read THIS definition, found no timeout and returned — so the child sat here forever with a deadline that could never arrive. Do not 'fix' this file by giving the child its own timeout; that would delete the regression.", + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Timeout Lab — child with no timeout of its own" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-timeout-lab-child", + "target": "child-waiting", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Timeout Lab Child" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "child-waiting", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Waiting (only the parent's override deadline moves this)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "child-finish", + "target": "child-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Finish before the deadline" + } + ] + } + ] + }, + { + "key": "child-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "child-timedout", + "stateType": 3, + "subType": 8, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Timed out (target of the PARENT's timeout override)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} diff --git a/core/Workflows/timeout-lab/timeout-lab-parent.json b/core/Workflows/timeout-lab/timeout-lab-parent.json new file mode 100644 index 0000000..0d36c1f --- /dev/null +++ b/core/Workflows/timeout-lab/timeout-lab-parent.json @@ -0,0 +1,173 @@ +{ + "key": "timeout-lab-parent", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.0", + "tags": [ + "integration-test", + "timeout-lab", + "parent", + "timeout-override" + ], + "attributes": { + "_comment": "Supplies the child's deadline through subFlow.overrides.timeout — the field the engine used to accept, stamp and then ignore. PT20S so a test can watch it fire; the equivalent field on subflow-orchestration is PT15M and must stay that way, or that scenario's children would start cancelling mid-suite.", + "type": "F", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Timeout Lab — parent supplying a subflow timeout override" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-timeout-lab-parent", + "target": "parent-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Timeout Lab Parent" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "parent-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "auto-parent-to-subflow", + "target": "parent-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto to subflow" + } + ], + "triggerKind": 10 + } + ] + }, + { + "key": "parent-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "SubFlow (child runs under the parent's override deadline)" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "timeout-lab-child", + "domain": "core", + "version": "1.0.0", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/TimeoutLabSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gTWluaW1hbCBTdWJGbG93IG1hcHBpbmcgZm9yIHRoZSB0aW1lb3V0IGxhYjogdGhlIHNjZW5hcmlvIGlzIGFib3V0IHRoZSBjaGlsZCdzIERFQURMSU5FLCBub3QKLy8vIGFib3V0IGRhdGEsIHNvIG5vdGhpbmcgaXMgbWFwcGVkIGluIGVpdGhlciBkaXJlY3Rpb24gYmV5b25kIGEgbWFya2VyIHRoZSBhc3NlcnRpb25zIGNhbiByZWFkLgovLy8gPC9zdW1tYXJ5PgovLy8gPHJlbWFya3M+Ci8vLyBJdCBleGlzdHMgYmVjYXVzZSA8Yz5zdWJGbG93Lm1hcHBpbmc8L2M+IGlzIHJlcXVpcmVkIGJ5IHRoZSBzY2hlbWEsIG5vdCBiZWNhdXNlIHRoZSBzY2VuYXJpbwovLy8gbmVlZHMgYSB0cmFuc2Zvcm1hdGlvbi4gS2VlcGluZyBpdCBlbXB0eSBpcyBkZWxpYmVyYXRlIOKAlCBhIG1hcHBpbmcgdGhhdCBtb3ZlZCByZWFsIGRhdGEgd291bGQKLy8vIGdpdmUgYSBmYWlsaW5nIHRlc3QgYSBzZWNvbmQgcG9zc2libGUgY2F1c2UuCi8vLyA8L3JlbWFya3M+CnB1YmxpYyBjbGFzcyBUaW1lb3V0TGFiU3ViRmxvd01hcHBpbmcgOiBTY3JpcHRCYXNlLCBJU3ViRmxvd01hcHBpbmcKewogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IElucHV0SGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgZHluYW1pYyBjaGlsZElucHV0ID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICBjaGlsZElucHV0LnN0YXJ0ZWRCeVRpbWVvdXRMYWJQYXJlbnQgPSB0cnVlOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIGR5bmFtaWMgbWVyZ2VkID0gbmV3IEV4cGFuZG9PYmplY3QoKTsKICAgICAgICB2YXIgdGFyZ2V0ID0gKElEaWN0aW9uYXJ5PHN0cmluZywgb2JqZWN0PiltZXJnZWQ7CiAgICAgICAgdmFyIGluc3QgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwogICAgICAgIGlmIChpbnN0ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBmb3JlYWNoICh2YXIga3YgaW4gaW5zdCkKICAgICAgICAgICAgewogICAgICAgICAgICAgICAgdGFyZ2V0W2t2LktleV0gPSBrdi5WYWx1ZTsKICAgICAgICAgICAgfQogICAgICAgIH0KICAgICAgICB0YXJnZXRbImNoaWxkU2V0dGxlZCJdID0gdHJ1ZTsKICAgICAgICByZXR1cm4gVGFzay5Gcm9tUmVzdWx0KG5ldyBTY3JpcHRSZXNwb25zZSB7IERhdGEgPSBtZXJnZWQgfSk7CiAgICB9Cn0K" + }, + "overrides": { + "timeout": { + "key": "child-abandoned", + "target": "child-timedout", + "versionStrategy": "Minor", + "timer": { + "reset": "never", + "duration": "PT20S" + } + } + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "auto-parent-to-completed", + "target": "parent-completed", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Auto to completed" + } + ], + "triggerKind": 10 + } + ] + }, + { + "key": "parent-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "parent-cancelled", + "stateType": 3, + "subType": 3, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ], + "cancel": { + "key": "cancel-timeout-lab-parent", + "target": "parent-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel Timeout Lab Parent" + } + ] + } + } +} \ No newline at end of file diff --git a/core/Workflows/timeout-lab/timeout-lab-root.json b/core/Workflows/timeout-lab/timeout-lab-root.json new file mode 100644 index 0000000..c3ed2cc --- /dev/null +++ b/core/Workflows/timeout-lab/timeout-lab-root.json @@ -0,0 +1,143 @@ +{ + "key": "timeout-lab-root", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.0", + "tags": [ + "integration-test", + "timeout-lab", + "workflow-timeout", + "state-function" + ], + "attributes": { + "_comment": "A workflow-level timeout short enough to fire inside a test run. Nothing else in this repo could exercise one: the only two authored timeouts are PT15M. The instance parks in root-waiting doing nothing, so the deadline is the only thing that moves it — which is what lets a test assert both halves of the contract in one run: the state function's `timeout` block while it is pending, and the instance actually landing on `target` when it fires. NOTE the duration is an absolute budget from START, not an idle window: timer.reset is required by the schema and read nowhere in the runtime.", + "type": "F", + "timeout": { + "key": "root-abandoned", + "target": "root-timedout", + "versionStrategy": "Minor", + "timer": { + "reset": "never", + "duration": "PT20S" + } + }, + "labels": [ + { + "language": "en-US", + "label": "Timeout Lab — root flow with a workflow-level timeout" + } + ], + "functions": [], + "features": [], + "extensions": [], + "startTransition": { + "key": "start-timeout-lab-root", + "target": "root-waiting", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Timeout Lab Root" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "root-waiting", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Waiting (the timeout is the only thing that moves this)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "root-finish", + "target": "root-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Finish before the deadline" + } + ] + } + ] + }, + { + "key": "root-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "root-timedout", + "stateType": 3, + "subType": 8, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Timed out (pulled here by the workflow timeout)" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "root-cancelled", + "stateType": 3, + "subType": 3, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ], + "cancel": { + "key": "cancel-timeout-lab-root", + "target": "root-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel Timeout Lab Root" + } + ] + } + } +} diff --git a/credit/Workflows/human-task-chain/ht-e.json b/credit/Workflows/human-task-chain/ht-e.json new file mode 100644 index 0000000..fa0094f --- /dev/null +++ b/credit/Workflows/human-task-chain/ht-e.json @@ -0,0 +1,254 @@ +{ + "key": "ht-e", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "credit", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "credit" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-E (credit)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-e", + "target": "ht-e-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-e" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-e-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-e", + "target": "ht-e-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-e" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-e-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-e-descend", + "target": "ht-e-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e: descend to ht-f" + } + ], + "rule": { + "location": "./src/DescendRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBEZXNjZW5kIHdoaWxlIGBob3BzYCBpcyBwb3NpdGl2ZS4gRXZlcnkgc3ViZmxvdyBtYXBwaW5nIGRlY3JlbWVudHMgaXQsIHNvIHRoZSBwYXlsb2FkJ3MgaW5pdGlhbAovLy8gdmFsdWUgYWxvbmUgZGVjaWRlcyB3aGljaCBsZXZlbCBlbmRzIHVwIGhvbGRpbmcgdGhlIGh1bWFuIHRhc2sg4oCUIHRoZSBkZWZpbml0aW9uIGNoYWluIGlzIHRoZQovLy8gc2FtZSBmb3IgYWxsIHRocmVlIHNjZW5hcmlvcy4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIERlc2NlbmRSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJob3BzIiwgb3V0IHZhciByYXcpICYmIHJhdyAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgaW50LlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgaG9wcyk7CiAgICAgICAgfQoKICAgICAgICB2YXIgZGVzY2VuZCA9IGhvcHMgPiAwOwogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiRGVzY2VuZFJ1bGU6IGhvcHM9e2hvcHN9IGRlc2NlbmQ9e2Rlc2NlbmR9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChkZXNjZW5kKTsKICAgIH0KfQo=" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-e-to-human", + "target": "ht-e-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-e-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e subflow" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "ht-f", + "domain": "credit", + "version": "1.0.6", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/HteToNextSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gSGFuZHMgdGhlIGNoYWluIGRvd24gb25lIGxldmVsOiBkZWNyZW1lbnRzIGBob3BzYCwgY2FycmllcyBgdGVzdElkYCwgYW5kIGdpdmVzIHRoZSBjaGlsZCBpdHMgT1dOCi8vLyBodW1hblRhc2sgdGV4dC4gVGhlIGRpc3RpbmN0IHRleHQgcGVyIGxldmVsIGlzIHRoZSBwb2ludCDigJQgYSB0ZXN0IGNhbiB0aGVuIHRlbGwgd2hldGhlciB0aGUKLy8vIGxpc3RlZCB0aXRsZSBjYW1lIGZyb20gdGhlIGxlYWYgb3Igd2FzIHRha2VuIGZyb20gYW4gYW5jZXN0b3IuCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBIdGVUb05leHRTdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBob3BzID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoImhvcHMiLCBvdXQgdmFyIHJhdykgJiYgcmF3ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBpbnQuVHJ5UGFyc2UocmF3LlRvU3RyaW5nKCksIG91dCBob3BzKTsKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgY2hpbGRJbnB1dC5ob3BzID0gaG9wcyAtIDE7CgogICAgICAgIGlmIChkYXRhICE9IG51bGwgJiYgZGF0YS5UcnlHZXRWYWx1ZSgidGVzdElkIiwgb3V0IHZhciB0ZXN0SWQpICYmIHRlc3RJZCAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgY2hpbGRJbnB1dC50ZXN0SWQgPSB0ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBkeW5hbWljIGh1bWFuVGFzayA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaHVtYW5UYXNrLnRpdGxlID0gIkhULUYgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUYgc3RlcCBkZXNjcmlwdGlvbiI7CiAgICAgICAgY2hpbGRJbnB1dC5odW1hblRhc2sgPSBodW1hblRhc2s7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiSHRlVG9OZXh0U3ViRmxvd01hcHBpbmc6IGRlc2NlbmRpbmcgdG8gSFQtRiB3aXRoIGhvcHM9e2hvcHMgLSAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-e-subflow-done", + "target": "ht-e-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e: subflow finished" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-e-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-e-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-e-approve", + "target": "ht-e-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-e-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-e-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-e-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-e cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} diff --git a/credit/Workflows/human-task-chain/ht-f.json b/credit/Workflows/human-task-chain/ht-f.json new file mode 100644 index 0000000..e60ec20 --- /dev/null +++ b/credit/Workflows/human-task-chain/ht-f.json @@ -0,0 +1,193 @@ +{ + "key": "ht-f", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "credit", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "credit" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-F (credit)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-f", + "target": "ht-f-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-f" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-f-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-f", + "target": "ht-f-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-f" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-f-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-f-to-human", + "target": "ht-f-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-f-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-f-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-f-approve", + "target": "ht-f-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-f-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-f-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-f-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-f cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} diff --git a/credit/Workflows/human-task-chain/src/DescendRule.csx b/credit/Workflows/human-task-chain/src/DescendRule.csx new file mode 100644 index 0000000..5246b4a --- /dev/null +++ b/credit/Workflows/human-task-chain/src/DescendRule.csx @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Descend while `hops` is positive. Every subflow mapping decrements it, so the payload's initial +/// value alone decides which level ends up holding the human task — the definition chain is the +/// same for all three scenarios. +/// +public class DescendRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + var descend = hops > 0; + LogInformation($"DescendRule: hops={hops} descend={descend}"); + return Task.FromResult(descend); + } +} diff --git a/credit/Workflows/human-task-chain/src/HteToNextSubFlowMapping.csx b/credit/Workflows/human-task-chain/src/HteToNextSubFlowMapping.csx new file mode 100644 index 0000000..25c54b2 --- /dev/null +++ b/credit/Workflows/human-task-chain/src/HteToNextSubFlowMapping.csx @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class HteToNextSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-F step"; + humanTask.description = "HT-F step description"; + childInput.humanTask = humanTask; + + LogInformation($"HteToNextSubFlowMapping: descending to HT-F with hops={hops - 1}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/etc/docker/config/seed/morph-idm-roles-collection.json b/etc/docker/config/seed/morph-idm-roles-collection.json new file mode 100644 index 0000000..ff0c8c9 --- /dev/null +++ b/etc/docker/config/seed/morph-idm-roles-collection.json @@ -0,0 +1,67 @@ +{ + "collection": { + "name": "morph-idm-roles", + "description": "get-roles endpoint for the morph-idm caller-role provider. Only the authorization-chain-lab's MorphIdmProviderTests use it, and only while the runtime runs with CallerRoleProvider:Provider=morph-idm.", + "color": "#0ea5e9" + }, + "folders": [], + "mocks": [ + { + "httpMethod": "GET", + "route": "api/1/morph-idm/functions/get-roles", + "queryString": null, + "requestBody": "", + "statusCode": 200, + "responseBody": "{\"roles\":[\"chain.admin\",\"chain.mid-admin\",\"chain.leaf-admin\",\"chain.reader\"]}", + "contentType": "application/json", + "description": "Operation set of the caller, keyed on the `sub` header the resolver sends. The runtime asks WITHOUT a role header on purpose: with one the real endpoint switches to a yes/no check for that single role, and the runtime needs the whole set. The unkeyed default hands the suite's ordinary caller a working set so the chain can be assembled; the four keyed users are the ones the assertions turn on.", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [ + { + "conditionField": "header.sub", + "conditionOperator": "equals", + "conditionValue": "idm-admin", + "statusCode": 200, + "responseBody": "{\"roles\":[\"chain.admin\"]}", + "contentType": "application/json", + "priority": 0, + "responseHeaders": [] + }, + { + "conditionField": "header.sub", + "conditionOperator": "equals", + "conditionValue": "idm-leaf-only", + "statusCode": 200, + "responseBody": "{\"roles\":[\"chain.leaf-only\"]}", + "contentType": "application/json", + "priority": 1, + "responseHeaders": [] + }, + { + "conditionField": "header.sub", + "conditionOperator": "equals", + "conditionValue": "idm-empty", + "statusCode": 204, + "responseBody": "", + "contentType": "application/json", + "priority": 2, + "responseHeaders": [] + }, + { + "conditionField": "header.sub", + "conditionOperator": "equals", + "conditionValue": "idm-broken", + "statusCode": 500, + "responseBody": "{\"error\":\"provider unavailable\"}", + "contentType": "application/json", + "priority": 3, + "responseHeaders": [] + } + ], + "sequenceItems": [] + } + ] +} \ No newline at end of file diff --git a/etc/docker/config/seed/task-invocation-lab-collection.json b/etc/docker/config/seed/task-invocation-lab-collection.json new file mode 100644 index 0000000..2516b22 --- /dev/null +++ b/etc/docker/config/seed/task-invocation-lab-collection.json @@ -0,0 +1,106 @@ +{ + "collection": { + "name": "task-invocation-lab", + "description": "Deterministic backends for the task-invocation-lab integration scenario (issue #1007: Http/DaprService/Soap/StateStore/CacheAside run in-process on Orchestration now)", + "color": "#3b82f6" + }, + "folders": [], + "mocks": [ + { + "httpMethod": "POST", + "route": "api/til/ok", + "queryString": null, + "requestBody": "", + "statusCode": 200, + "responseBody": "{\n \"ok\": true,\n \"source\": \"task-invocation-lab\"\n}", + "contentType": "application/json", + "description": "200 success -- shared by til-http-ok and til-dapr-ok (via Dapr service invocation to the mocklab app-id)", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + }, + { + "httpMethod": "POST", + "route": "api/til/fail-500", + "queryString": null, + "requestBody": "", + "statusCode": 500, + "responseBody": "{\n \"error\": {\n \"code\": \"TIL_500\",\n \"message\": \"Deliberate 500 for task-invocation-lab\"\n }\n}", + "contentType": "application/json", + "description": "Always 500 -- til-http-500's task-level Notify boundary", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + }, + { + "httpMethod": "POST", + "route": "api/til/slow", + "queryString": null, + "requestBody": "", + "statusCode": 200, + "responseBody": "{\n \"ok\": true,\n \"slow\": true\n}", + "contentType": "application/json", + "description": "Responds after 5s; til-http-slow's timeoutSeconds=2 fires first -- task-level Rollback boundary", + "delayMs": 5000, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + }, + { + "httpMethod": "POST", + "route": "api/til/soap-ok", + "queryString": null, + "requestBody": "", + "statusCode": 200, + "responseBody": "\n\n \n \n pong\n \n \n", + "contentType": "text/xml", + "description": "SOAP 1.1 success envelope for til-soap-ok", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + }, + { + "httpMethod": "POST", + "route": "api/til/soap-fault", + "queryString": null, + "requestBody": "", + "statusCode": 500, + "responseBody": "\n\n \n \n soap:Server\n Deliberate SOAP fault for task-invocation-lab\n \n \n", + "contentType": "text/xml", + "description": "SOAP 1.1 Fault envelope over HTTP 500 for til-soap-fault -- task-level Abort boundary", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + }, + { + "httpMethod": "GET", + "route": "api/til/cache-source", + "queryString": null, + "requestBody": "", + "statusCode": 200, + "responseBody": "{\n \"value\": \"til-cache-source-value\",\n \"generatedBy\": \"mocklab\"\n}", + "contentType": "application/json", + "description": "Source task wrapped by til-cacheaside on a cache miss", + "delayMs": null, + "isActive": true, + "isSequential": false, + "folderIndex": null, + "rules": [], + "sequenceItems": [] + } + ] +} diff --git a/labs/cross-domain/README.md b/labs/cross-domain/README.md index 98bee2b..fd412e2 100644 --- a/labs/cross-domain/README.md +++ b/labs/cross-domain/README.md @@ -1,16 +1,25 @@ # Cross-Domain Lab -Üç vNext domain'i tek Docker ağında ayağa kaldıran kalıcı lokal ortam. `core` ve `partner` **lokalde +Dört vNext domain'i tek Docker ağında ayağa kaldıran kalıcı lokal ortam. `core`, `partner` ve `credit` **lokalde derlenen** runtime imajlarıyla koşar, açılışta kendilerini `discovery` domain'ine kaydeder ve birbirlerine **Dapr Name Resolution + Service Invocation** ile ulaşır (`ServiceDiscovery:Provider=dapr`). Cross-domain davranış (SubFlow/SubProcess, trigger task'ları, fonksiyon descent'i) buraya karşı ölçülür. +## Neden dört domain + +İki iş domain'i bir sınırı kanıtlar, üçü **ikincisini** kanıtlar — ve ikinci sınırı geçen, istemcinin çağırdığı +runtime değildir. `human-task-chain` senaryosunda zincir `core → partner → credit` şeklinde iner ve +`partner → credit` hop'unu **partner'ın kendi runtime'ı** yapar; `core` credit ile hiç konuşmaz. Üçüncü iş +domain'i olmadan tam da o hop test edilmemiş kalır. `lab.sh verify` bu yüzden iki sidecar invoke'unu birden +dener (`core → partner` ve `partner → credit`). + ## Topoloji | Domain | Orchestrator | Init | Dapr app-id | İmaj | Rol | |---|---|---|---|---|---| | `core` | http://localhost:4201 | :3005 | `vnext-app-core` | `*:dapr-nr` (lokal) | parent akışlar, cross-domain task'lar | | `partner` | http://localhost:4211 | :3015 | `vnext-app-partner` | `*:dapr-nr` (lokal) | child / remote akışlar | +| `credit` | http://localhost:4221 | :3025 | `vnext-app-credit` | `*:dapr-nr` (lokal) | **ikinci** sınırın öbür tarafı — `partner`'ın çağırdığı domain | | `discovery` | http://localhost:4231 | :3035 | `vnext-app-discovery` | `*:dapr-nr` (lokal) | registry (`@burgan-tech/vnext-discovery-runtime`) | **Her üç domain de lokalde derlenen imajlarla koşar** (`orchestrator`, `execution`, `inbox`, `outbox`, diff --git a/labs/cross-domain/lab.sh b/labs/cross-domain/lab.sh index 2903e71..ef28c85 100755 --- a/labs/cross-domain/lab.sh +++ b/labs/cross-domain/lab.sh @@ -18,7 +18,8 @@ # VNEXT_SRC_DIR vnext runtime source checkout (default: ../vnext next to vnext-example) # VNEXT_LAB_IMAGE_TAG image tag for core/partner (default: dapr-nr) # VNEXT_DISCOVERY_IMAGE_TAG image tag for the discovery domain (default: latest) -# VNEXT_DISCOVERY_PACKAGE / _VERSION npm package published into discovery (default: @burgan-tech/vnext-discovery-runtime 0.0.6) +# VNEXT_DISCOVERY_PACKAGE / _VERSION npm package published into discovery (default: @burgan-tech/vnext-discovery-runtime 0.0.7 +# — 0.0.7 is the first with the domain-list function the cache warm-up reads) set -euo pipefail LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -39,15 +40,15 @@ DAPR_VERSION="${VNEXT_LAB_DAPR_VERSION:-1.18.0}" PROVIDER="${VNEXT_LAB_DISCOVERY_PROVIDER:-dapr}" case "$PROVIDER" in dapr|http) ;; *) echo "VNEXT_LAB_DISCOVERY_PROVIDER must be dapr or http" >&2; exit 1;; esac DISCOVERY_PKG="${VNEXT_DISCOVERY_PACKAGE:-@burgan-tech/vnext-discovery-runtime}" -DISCOVERY_PKG_VERSION="${VNEXT_DISCOVERY_PACKAGE_VERSION:-0.0.6}" +DISCOVERY_PKG_VERSION="${VNEXT_DISCOVERY_PACKAGE_VERSION:-0.0.7}" NETWORK="vnext-development" OVERLAY_MARKER="# --- cross-domain lab overlay" # domain -> port offset (create-domain.sh: app 4201+off, init 3005+off, dapr http 42110+off*100) # (a case, not an associative array: macOS ships bash 3.2) -offset_of() { case "$1" in core) echo 0;; partner) echo 10;; discovery) echo 30;; *) die "unknown domain $1";; esac; } -DOMAINS_APP=(core partner) # register themselves + run local images -ALL_DOMAINS=(discovery core partner) +offset_of() { case "$1" in core) echo 0;; partner) echo 10;; credit) echo 20;; discovery) echo 30;; *) die "unknown domain $1";; esac; } +DOMAINS_APP=(core partner credit) # register themselves + run local images +ALL_DOMAINS=(discovery core partner credit) # compose services per domain, WITHOUT vnext-component-publisher (it publishes core-runtime, not ours) SERVICES="vnext-db-migrator vnext-db-migrator-dapr vnext-app vnext-orchestration-dapr vnext-execution-app vnext-execution-dapr vnext-worker-inbox vnext-worker-inbox-dapr vnext-worker-outbox vnext-worker-outbox-dapr vnext-init" @@ -138,10 +139,15 @@ wait_health() { publish_discovery_package() { local port; port=$(init_port discovery) - local probe; probe=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$(app_port discovery)/api/v1/discovery/functions/domain-lookup?key=__probe__") - if [ "$probe" = 404 ] && curl -s "http://localhost:$(app_port discovery)/api/v1/discovery/functions/domain-lookup?key=__probe__" | grep -q domainName; then - ok "discovery components already published"; return + # Probe domain-list, NOT domain-lookup. Both packages carry domain-lookup, so probing it says + # "already published" for a pre-0.0.7 package too — and leaves missing exactly the function this + # version pin exists for, the one the cache warm-up and morph-idm-api's aggregation both read. + # A 404 with errorCode Cache:300001 means the function itself is absent from the runtime backend. + local body; body=$(curl -s "http://localhost:$(app_port discovery)/api/v1/discovery/functions/domain-list") + if ! echo "$body" | grep -q 'Cache:300001'; then + ok "discovery components already published (domain-list present)"; return fi + log "domain-list missing — (re)publishing the discovery package" log "publishing $DISCOVERY_PKG@$DISCOVERY_PKG_VERSION into discovery via init :$port" local job; job=$(curl -s -X POST "http://localhost:$port/api/package/publish" -H 'Content-Type: application/json' \ -d "{\"packageName\":\"$DISCOVERY_PKG\",\"version\":\"$DISCOVERY_PKG_VERSION\",\"reInitialize\":true}" \ @@ -209,8 +215,15 @@ cmd_verify() { local inv; inv=$(docker exec vnext-app-core sh -c "wget -qO- -S http://localhost:$(dapr_http core)/v1.0/invoke/vnext-app-partner/method/health 2>&1 | head -1" 2>/dev/null || true) if echo "$inv" | grep -q '200'; then ok "core sidecar -> vnext-app-partner /health: $inv" else fail "core sidecar could not invoke vnext-app-partner: ${inv:-no response}"; rc=1; fi + + # The SECOND boundary. A chain like human-task-chain crosses core -> partner -> credit, and the + # partner runtime makes that second hop itself — core never talks to credit. Proving only + # core -> partner would leave exactly that hop unverified. + local inv2; inv2=$(docker exec vnext-app-partner sh -c "wget -qO- -S http://localhost:$(dapr_http partner)/v1.0/invoke/vnext-app-credit/method/health 2>&1 | head -1" 2>/dev/null || true) + if echo "$inv2" | grep -q '200'; then ok "partner sidecar -> vnext-app-credit /health: $inv2" + else fail "partner sidecar could not invoke vnext-app-credit: ${inv2:-no response}"; rc=1; fi local prov; prov=$(docker exec vnext-app-core sh -c 'echo $ServiceDiscovery__Provider' 2>/dev/null || echo "?") - [ $rc -eq 0 ] && ok "lab ready: core :$(app_port core) partner :$(app_port partner) discovery :$(app_port discovery) (ServiceDiscovery provider: ${prov:-?})" + [ $rc -eq 0 ] && ok "lab ready: core :$(app_port core) partner :$(app_port partner) credit :$(app_port credit) discovery :$(app_port discovery) (ServiceDiscovery provider: ${prov:-?})" return $rc } diff --git a/labs/cross-domain/orchestration.overlay.env b/labs/cross-domain/orchestration.overlay.env index ffe70e3..90f58a8 100644 --- a/labs/cross-domain/orchestration.overlay.env +++ b/labs/cross-domain/orchestration.overlay.env @@ -7,6 +7,14 @@ ServiceDiscovery__BaseUrl=http://vnext-app-discovery:5000/api/v1 ServiceDiscovery__Domain=discovery ServiceDiscovery__RegistryFlow=domain-registration ServiceDiscovery__DiscoveryEndpointTemplate=/discovery/functions/domain-lookup?key={0} +# Bulk warm-up: registry'nin domain-list function'ından tek okuma. Cache YALNIZ Provider=http'de +# register edilir (dapr provider app-id'yi konvansiyondan türetir, registry'ye gitmez), yani bu +# satır dapr altında etkisizdir ve http rollback tatbikatında devreye girer. +# NOT: bu "etkisizlik" 2026-09-18'e kadar doğru DEĞİLDİ — DiscoveryCacheRefreshHostedService +# kendi koşulunu (Enabled && Cache.Enabled) ayrıca yazdığı için dapr altında hiç register +# edilmemiş refresher'ı her tick çözmeye çalışıyor ve InvalidOperationException logluyordu. +# Servis artık registration'ı probe edip çıkıyor; satır gerçekten etkisiz. +ServiceDiscovery__Cache__Enabled=true # Lab app-ids are vnext-app-{domain} (runtime template), not the vnext-{domain}-app convention: # the registry appId override is what makes convention-based resolution land on the right sidecar. # BOTH flags are needed: the runtime default is RequireRegistryEntry=false (pure convention, no diff --git a/partner/Workflows/human-task-chain/ht-d.json b/partner/Workflows/human-task-chain/ht-d.json new file mode 100644 index 0000000..9b63617 --- /dev/null +++ b/partner/Workflows/human-task-chain/ht-d.json @@ -0,0 +1,280 @@ +{ + "key": "ht-d", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "partner", + "version": "1.0.6", + "tags": [ + "human-task-chain", + "human-task", + "subflow", + "partner" + ], + "attributes": { + "type": "S", + "timeout": null, + "labels": [ + { + "language": "en-US", + "label": "Human Task Chain HT-D (partner)" + } + ], + "functions": [], + "features": [], + "extensions": [], + "sharedTransitions": [], + "cancel": { + "key": "cancel-ht-d", + "target": "ht-d-cancelled", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Cancel ht-d" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-d-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + }, + "startTransition": { + "key": "start-ht-d", + "target": "ht-d-initial", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start ht-d" + } + ], + "onExecutionTasks": [] + }, + "states": [ + { + "key": "ht-d-initial", + "stateType": 1, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d initial" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-d-descend", + "target": "ht-d-subflow", + "triggerType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d: descend to ht-e" + } + ], + "rule": { + "location": "./src/DescendRule.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5UaHJlYWRpbmcuVGFza3M7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBEZXNjZW5kIHdoaWxlIGBob3BzYCBpcyBwb3NpdGl2ZS4gRXZlcnkgc3ViZmxvdyBtYXBwaW5nIGRlY3JlbWVudHMgaXQsIHNvIHRoZSBwYXlsb2FkJ3MgaW5pdGlhbAovLy8gdmFsdWUgYWxvbmUgZGVjaWRlcyB3aGljaCBsZXZlbCBlbmRzIHVwIGhvbGRpbmcgdGhlIGh1bWFuIHRhc2sg4oCUIHRoZSBkZWZpbml0aW9uIGNoYWluIGlzIHRoZQovLy8gc2FtZSBmb3IgYWxsIHRocmVlIHNjZW5hcmlvcy4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIERlc2NlbmRSdWxlIDogU2NyaXB0QmFzZSwgSUNvbmRpdGlvbk1hcHBpbmcKewogICAgcHVibGljIFRhc2s8Ym9vbD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIGRhdGEgPSBjb250ZXh0Lkluc3RhbmNlLkRhdGEgYXMgSURpY3Rpb25hcnk8c3RyaW5nLCBvYmplY3Q+OwoKICAgICAgICB2YXIgaG9wcyA9IDA7CiAgICAgICAgaWYgKGRhdGEgIT0gbnVsbCAmJiBkYXRhLlRyeUdldFZhbHVlKCJob3BzIiwgb3V0IHZhciByYXcpICYmIHJhdyAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgaW50LlRyeVBhcnNlKHJhdy5Ub1N0cmluZygpLCBvdXQgaG9wcyk7CiAgICAgICAgfQoKICAgICAgICB2YXIgZGVzY2VuZCA9IGhvcHMgPiAwOwogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiRGVzY2VuZFJ1bGU6IGhvcHM9e2hvcHN9IGRlc2NlbmQ9e2Rlc2NlbmR9Iik7CiAgICAgICAgcmV0dXJuIFRhc2suRnJvbVJlc3VsdChkZXNjZW5kKTsKICAgIH0KfQo=" + }, + "onExecutionTasks": [] + }, + { + "key": "ht-d-to-human", + "target": "ht-d-human", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d: rest in the human state" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-d-subflow", + "stateType": 4, + "subType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d subflow" + } + ], + "view": null, + "subFlow": { + "type": "S", + "process": { + "key": "ht-e", + "domain": "credit", + "version": "1.0.6", + "flow": "sys-flows" + }, + "mapping": { + "location": "./src/HtdToNextSubFlowMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuU2NyaXB0aW5nOwoKLy8vIDxzdW1tYXJ5PgovLy8gSGFuZHMgdGhlIGNoYWluIGRvd24gb25lIGxldmVsOiBkZWNyZW1lbnRzIGBob3BzYCwgY2FycmllcyBgdGVzdElkYCwgYW5kIGdpdmVzIHRoZSBjaGlsZCBpdHMgT1dOCi8vLyBodW1hblRhc2sgdGV4dC4gVGhlIGRpc3RpbmN0IHRleHQgcGVyIGxldmVsIGlzIHRoZSBwb2ludCDigJQgYSB0ZXN0IGNhbiB0aGVuIHRlbGwgd2hldGhlciB0aGUKLy8vIGxpc3RlZCB0aXRsZSBjYW1lIGZyb20gdGhlIGxlYWYgb3Igd2FzIHRha2VuIGZyb20gYW4gYW5jZXN0b3IuCi8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBIdGRUb05leHRTdWJGbG93TWFwcGluZyA6IFNjcmlwdEJhc2UsIElTdWJGbG93TWFwcGluZwp7CiAgICBwdWJsaWMgVGFzazxTY3JpcHRSZXNwb25zZT4gSW5wdXRIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgZGF0YSA9IGNvbnRleHQuSW5zdGFuY2UuRGF0YSBhcyBJRGljdGlvbmFyeTxzdHJpbmcsIG9iamVjdD47CgogICAgICAgIHZhciBob3BzID0gMDsKICAgICAgICBpZiAoZGF0YSAhPSBudWxsICYmIGRhdGEuVHJ5R2V0VmFsdWUoImhvcHMiLCBvdXQgdmFyIHJhdykgJiYgcmF3ICE9IG51bGwpCiAgICAgICAgewogICAgICAgICAgICBpbnQuVHJ5UGFyc2UocmF3LlRvU3RyaW5nKCksIG91dCBob3BzKTsKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgY2hpbGRJbnB1dCA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgY2hpbGRJbnB1dC5ob3BzID0gaG9wcyAtIDE7CgogICAgICAgIGlmIChkYXRhICE9IG51bGwgJiYgZGF0YS5UcnlHZXRWYWx1ZSgidGVzdElkIiwgb3V0IHZhciB0ZXN0SWQpICYmIHRlc3RJZCAhPSBudWxsKQogICAgICAgIHsKICAgICAgICAgICAgY2hpbGRJbnB1dC50ZXN0SWQgPSB0ZXN0SWQ7CiAgICAgICAgfQoKICAgICAgICBkeW5hbWljIGh1bWFuVGFzayA9IG5ldyBFeHBhbmRvT2JqZWN0KCk7CiAgICAgICAgaHVtYW5UYXNrLnRpdGxlID0gIkhULUUgc3RlcCI7CiAgICAgICAgaHVtYW5UYXNrLmRlc2NyaXB0aW9uID0gIkhULUUgc3RlcCBkZXNjcmlwdGlvbiI7CiAgICAgICAgY2hpbGRJbnB1dC5odW1hblRhc2sgPSBodW1hblRhc2s7CgogICAgICAgIExvZ0luZm9ybWF0aW9uKCQiSHRkVG9OZXh0U3ViRmxvd01hcHBpbmc6IGRlc2NlbmRpbmcgdG8gSFQtRSB3aXRoIGhvcHM9e2hvcHMgLSAxfSIpOwogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGNoaWxkSW5wdXQgfSk7CiAgICB9CgogICAgcHVibGljIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgewogICAgICAgIHJldHVybiBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlKCkpOwogICAgfQp9Cg==" + }, + "overrides": { + "states": { + "ht-e-human": { + "queryRoles": [ + { + "role": "xd-override-only", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ] + } + }, + "transitions": { + "ht-e-approve": { + "roles": [ + { + "role": "xd-override-only", + "grant": "allow" + } + ] + } + } + } + }, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-d-subflow-done", + "target": "ht-d-completed", + "triggerType": 1, + "triggerKind": 10, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d: subflow finished" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-d-human", + "stateType": 2, + "subType": 6, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d human task" + } + ], + "queryRoles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-d-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "ht-d-approve", + "target": "ht-d-completed", + "triggerType": 0, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d: approve" + } + ], + "roles": [ + { + "role": "ht-approver", + "grant": "allow" + }, + { + "role": "ht-d-approver", + "grant": "allow" + }, + { + "role": "ht-blocked", + "grant": "deny" + } + ], + "onExecutionTasks": [] + } + ] + }, + { + "key": "ht-d-completed", + "stateType": 3, + "subType": 1, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d completed" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + }, + { + "key": "ht-d-cancelled", + "stateType": 3, + "subType": 7, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "ht-d cancelled" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} \ No newline at end of file diff --git a/partner/Workflows/human-task-chain/src/DescendRule.csx b/partner/Workflows/human-task-chain/src/DescendRule.csx new file mode 100644 index 0000000..5246b4a --- /dev/null +++ b/partner/Workflows/human-task-chain/src/DescendRule.csx @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Descend while `hops` is positive. Every subflow mapping decrements it, so the payload's initial +/// value alone decides which level ends up holding the human task — the definition chain is the +/// same for all three scenarios. +/// +public class DescendRule : ScriptBase, IConditionMapping +{ + public Task Handler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + var descend = hops > 0; + LogInformation($"DescendRule: hops={hops} descend={descend}"); + return Task.FromResult(descend); + } +} diff --git a/partner/Workflows/human-task-chain/src/HtdToNextSubFlowMapping.csx b/partner/Workflows/human-task-chain/src/HtdToNextSubFlowMapping.csx new file mode 100644 index 0000000..553afc9 --- /dev/null +++ b/partner/Workflows/human-task-chain/src/HtdToNextSubFlowMapping.csx @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Hands the chain down one level: decrements `hops`, carries `testId`, and gives the child its OWN +/// humanTask text. The distinct text per level is the point — a test can then tell whether the +/// listed title came from the leaf or was taken from an ancestor. +/// +public class HtdToNextSubFlowMapping : ScriptBase, ISubFlowMapping +{ + public Task InputHandler(ScriptContext context) + { + var data = context.Instance.Data as IDictionary; + + var hops = 0; + if (data != null && data.TryGetValue("hops", out var raw) && raw != null) + { + int.TryParse(raw.ToString(), out hops); + } + + dynamic childInput = new ExpandoObject(); + childInput.hops = hops - 1; + + if (data != null && data.TryGetValue("testId", out var testId) && testId != null) + { + childInput.testId = testId; + } + + dynamic humanTask = new ExpandoObject(); + humanTask.title = "HT-E step"; + humanTask.description = "HT-E step description"; + childInput.humanTask = humanTask; + + LogInformation($"HtdToNextSubFlowMapping: descending to HT-E with hops={hops - 1}"); + return Task.FromResult(new ScriptResponse { Data = childInput }); + } + + public Task OutputHandler(ScriptContext context) + { + return Task.FromResult(new ScriptResponse()); + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AckAndParentRetainedTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AckAndParentRetainedTests.cs new file mode 100644 index 0000000..759d31c --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AckAndParentRetainedTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// The ack target and the parent-retained transitions — the two places where +/// authorize must mirror a behaviour that lives somewhere else in the runtime. +/// +/// +/// Why this exists. POST .../longpoll/ack is the only state-changing surface in +/// the authorization change's scope, and the middle tier had no way to ask about it: authorize +/// took a transition key, a function key or queryRoles, none of which describe an +/// acknowledge. The new ?ack=true target closes that, and it has to agree with the endpoint +/// exactly — a pre-flight that answers a different question is worse than none. +/// The parent-retention half is the mirror-image risk: with an active SubFlow, cancel, +/// exit, updateData and an in-state shared transition all execute on the PARENT +/// (HandleCancelPreflightStep at order 5 skips over the forward at order 10; the forward step +/// excludes the other two). authorize must answer against the parent for exactly those and +/// descend for everything else. +/// +public sealed class AckAndParentRetainedTests : AuthorizationChainLabTestBase +{ + public AckAndParentRetainedTests(VNextTestEnvironment environment) : base(environment) { } + + // ── ack ────────────────────────────────────────────────────────────────── + + /// + /// The pre-flight and the call must give the same verdict. The leaf's + /// interaction.longPoll.roles names chain.admin; nothing else may acknowledge. + /// + /// + /// The assertion is AGREEMENT, not a fixed verdict. Whether the leaf is actually awaiting an + /// acknowledgement at the moment of the call depends on the fallback timer, and a test that hard + /// -coded "denied" would fail for the wrong reason once the pause has been resumed. What must hold + /// in every case is that the pre-flight and the call answer the same thing. + /// + [Theory] + [InlineData(Admin)] + [InlineData(LeafAdmin)] + [InlineData(null)] + public async Task AuthorizeAckAgreesWithTheAcknowledgeEndpoint(string? roles) + { + var chain = await StartChainAsync(); + + var preflight = await IsAuthorizedAsync(Leaf, chain.LeafId, roles, ack: true); + + var (ackStatus, _) = await SendRawAsync( + HttpMethod.Post, + $"api/v1/core/workflows/{Leaf}/instances/{chain.LeafId}/longpoll/ack", + headers: Headers(roles)); + + // The endpoint answers 403 on refusal and 2xx on admission. Anything else (a 404 because + // nothing is awaiting, say) means the fixture is not in the state this test assumes. + var endpointAllowed = ackStatus switch + { + HttpStatusCode.Forbidden => false, + _ when (int)ackStatus is >= 200 and < 300 => true, + _ => throw new Xunit.Sdk.XunitException( + $"the acknowledge endpoint answered {(int)ackStatus}; the pre-flight said " + + $"{(preflight ? "allowed" : "denied")} and this test cannot compare the two") + }; + + Assert.Equal(preflight, endpointAllowed); + } + + /// + /// Asking about an instance where nothing is awaiting must answer ALLOWED, because that is what + /// the endpoint does — it returns Ok() idempotently. A refusal here would have the middle tier + /// 403 a call the runtime accepts. + /// + [Fact] + public async Task AckIsAllowedWhenNothingIsAwaiting() + { + var chain = await StartChainAsync(); + + // The root is parked on its SubFlow state; it is not the instance that paused for an ack. + Assert.True(await IsAuthorizedAsync(Root, chain.RootId, Admin, ack: true), + "nothing in the chain above the leaf is awaiting an acknowledgement, and the endpoint " + + "answers Ok() there, so the pre-flight must say allowed"); + } + + /// Exactly one target is required — naming two is a client error, not a denial. + [Fact] + public async Task AckAndQueryRolesTogetherAreRejected() + { + var chain = await StartChainAsync(); + + var (status, _) = await AuthorizeAsync( + Root, chain.RootId, Admin, queryRoles: true, ack: true); + + Assert.Equal(HttpStatusCode.BadRequest, status); + } + + // ── parent-retained transitions ────────────────────────────────────────── + + /// + /// With an active SubFlow the parent retains these, so authorize must answer against the + /// ROOT's definition. The leaf declares none of them; if the call descended, every one of these + /// would be denied for want of a matching transition. + /// + [Theory] + [InlineData("cancel-root")] + [InlineData("exit-root")] + [InlineData("update-root-data")] + [InlineData("record-note")] + public async Task ParentRetainedTransitionsAreAnsweredAgainstTheRoot(string transitionKey) + { + var chain = await StartChainAsync(); + + Assert.True(await IsAuthorizedAsync(Root, chain.RootId, Admin, transitionKey: transitionKey), + $"{transitionKey} executes on the parent while a SubFlow is active, so authorize must " + + "answer from the root's definition rather than descending to a leaf that never declares it"); + } + + /// + /// The other side of the same rule: a key the parent does NOT retain is forwarded, and the leaf's + /// answer is the one that counts. finish-leaf exists only on the leaf. + /// + [Fact] + public async Task ANonRetainedTransitionIsAnsweredByTheLevelThatOwnsIt() + { + var chain = await StartChainAsync(); + + Assert.True(await IsAuthorizedAsync(Leaf, chain.LeafId, LeafAdmin, transitionKey: "finish-leaf"), + "finish-leaf is the leaf's own transition and the leaf's grants admit chain.leaf-admin"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AuthorizationChainLabTestBase.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AuthorizationChainLabTestBase.cs new file mode 100644 index 0000000..0a81729 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/AuthorizationChainLabTestBase.cs @@ -0,0 +1,205 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// Shared plumbing for the authorization-chain-lab suite. +/// +/// Everything here is about a STATUS CODE, so reads go out over +/// rather than the SDK client — the client surfaces the +/// parsed body and swallows the code, and a 403 is what most of these tests are looking for. +/// +/// +/// The chain assembles itself: starting the root walks automatically to the deepest leaf, so a test +/// only waits for the correlation map to reach depth two. Every assertion below is then about what +/// authorize says versus what the read surfaces actually do — those two answering differently +/// is the defect this lab exists to catch. +/// +/// +public abstract class AuthorizationChainLabTestBase : WorkflowTestBase +{ + // Three-level chain: root → mid → leaf. + protected const string Root = "authorization-chain-lab-root"; + protected const string Mid = "authorization-chain-lab-mid"; + protected const string Leaf = "authorization-chain-lab-leaf"; + + // Two-level pair, same terminal child. The override mechanism is only reachable on an instance + // with NO active SubFlow of its own (see StartTwoLevelAsync), which is why it lives here. + protected const string RootPlain = "authorization-chain-lab-root-plain"; + protected const string RootNarrow = "authorization-chain-lab-root-narrow"; + protected const string MidTerminal = "authorization-chain-lab-mid-terminal"; + + // ── roles ──────────────────────────────────────────────────────────────── + // Each role is chosen to fail at exactly one level, so a wrong verdict names its own cause. + // reader : root ✓ root's override of mid ✗ + // admin : root ✓ root's override of mid ✓ mid's override of leaf ✗ + // leafAdmin : root ✗ (mid's override of leaf would pass) + // midOnly : root ✗ mid's own queryRoles ✓ (only reachable via root-plain) + protected const string Reader = "chain.reader"; + protected const string Admin = "chain.admin"; + protected const string LeafAdmin = "chain.leaf-admin"; + protected const string MidOnly = "chain.mid-only"; + + /// + /// The propagation probe: granted by the ROOT's override of the mid and by the mid's own grants, + /// and deliberately absent from the mid's override of the leaf. If an ancestor's override ever + /// travelled past its direct child, this role would be admitted at the leaf. + /// + protected const string MidAdmin = "chain.mid-admin"; + + /// A caller holding no roles at all — neither header spelling is sent. + protected const string? NoRole = null; + + protected AuthorizationChainLabTestBase(VNextTestEnvironment environment) : base(environment) { } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + /// + /// Starts a chain and waits until the root holds an active correlation to the mid — which, since + /// the mid descends automatically too, means the leaf is live as well. + /// + /// + /// Started as : it is the only role that passes the root's own gate AND the + /// root's override of the mid, so the same caller can drive the fixture into place. Tests that + /// care about a narrower role read with that role afterwards; they do not need to start with it. + /// + /// + /// Starts a two-level chain (root → terminal mid) and waits for the correlation. + /// + /// Separate from for a measured reason: a parent's stamped override + /// is looked up by EffectiveState, which for an instance that ITSELF has an active SubFlow + /// is a descendant's state key — so the override never matches there and the gate falls back to + /// the workflow root's grants. Verified on the bench: with the root narrowing the mid to + /// chain.admin, chain.mid-only still read the mid 200. The override tests therefore run against a + /// child with nothing beneath it, where the mechanism is reachable. + /// + /// + protected async Task<(string RootId, string ChildId)> StartTwoLevelAsync(string workflow) + { + var rootId = await StartAsync(workflow, new { chainRef = $"two-{Guid.NewGuid():N}"[..24] }, Admin); + await AssertNotFaultedAsync(workflow, rootId, Admin); + + string? childId = null; + await WaitUntilAsync(async () => + { + var subs = await GetActiveSubflowsAsync(workflow, rootId, Admin); + return subs.TryGetValue(MidTerminal, out childId); + }, $"{workflow} {rootId} should open a correlation to {MidTerminal}"); + + return (rootId, childId!); + } + + protected async Task StartChainAsync(string workflow = Root) + { + var rootId = await StartAsync(workflow, new { chainRef = $"chain-{Guid.NewGuid():N}"[..24] }, Admin); + await AssertNotFaultedAsync(workflow, rootId, Admin); + + string? midId = null; + await WaitUntilAsync(async () => + { + var subs = await GetActiveSubflowsAsync(workflow, rootId, Admin); + return subs.TryGetValue(Mid, out midId); + }, $"{workflow} {rootId} should open a correlation to {Mid}"); + + string? leafId = null; + await WaitUntilAsync(async () => + { + var subs = await GetActiveSubflowsAsync(Mid, midId!, Admin); + return subs.TryGetValue(Leaf, out leafId); + }, $"{Mid} {midId} should open a correlation to {Leaf}"); + + return new ChainInstances(workflow, rootId, midId!, leafId!); + } + + // ── authorize ──────────────────────────────────────────────────────────── + + /// + /// Calls authorize against one level of the chain. Exactly one target may be supplied; + /// the runtime rejects a call naming none or more than one. + /// + protected async Task<(HttpStatusCode Status, JsonElement Body)> AuthorizeAsync( + string workflow, + string instanceId, + string? roles, + string? transitionKey = null, + bool queryRoles = false, + bool ack = false, + IDictionary? extraHeaders = null, + string? roleParameter = null) + { + var parts = new List(); + if (transitionKey is not null) parts.Add($"transitionKey={transitionKey}"); + if (queryRoles) parts.Add("queryRoles=true"); + if (ack) parts.Add("ack=true"); + // The `role` QUERY parameter — a different channel from the `role`/`x-roles` headers, and the + // one an authority provider must not honour. + if (roleParameter is not null) parts.Add($"role={Uri.EscapeDataString(roleParameter)}"); + + var url = $"api/v1/core/workflows/{workflow}/instances/{instanceId}/functions/authorize" + + (parts.Count == 0 ? "" : "?" + string.Join("&", parts)); + + var headers = Headers(roles); + if (extraHeaders is not null) + foreach (var (k, v) in extraHeaders) headers[k] = v; + + var (status, body) = await SendRawAsync(HttpMethod.Get, url, headers: headers); + return (status, Parse(body)); + } + + /// + /// True when authorize answered 200, false when it answered 403. + /// + /// The body carries the verdict on BOTH statuses ({"allowed": …}), so a consumer reading + /// only the 200 turns every refusal into "no answer". The assertion below is what keeps this + /// helper from quietly hiding a 404 or a 500 as a denial. + /// + /// + protected async Task IsAuthorizedAsync( + string workflow, string instanceId, string? roles, + string? transitionKey = null, bool queryRoles = false, bool ack = false, + IDictionary? extraHeaders = null, string? roleParameter = null) + { + var (status, body) = await AuthorizeAsync( + workflow, instanceId, roles, transitionKey, queryRoles, ack, extraHeaders, roleParameter); + + Assert.True(status is HttpStatusCode.OK or HttpStatusCode.Forbidden, + $"authorize answered {(int)status}, which is neither allowed (200) nor denied (403)"); + + if (body.ValueKind == JsonValueKind.Object && body.TryGetProperty("allowed", out var allowed)) + { + Assert.Equal(status == HttpStatusCode.OK, allowed.GetBoolean()); + } + + return status == HttpStatusCode.OK; + } + + // ── read surfaces ──────────────────────────────────────────────────────── + + /// Calls a built-in instance function and preserves the status code. + protected async Task<(HttpStatusCode Status, JsonElement Body)> CallFunctionAsync( + string workflow, string instanceId, string function, string? roles, string? query = null) + { + var url = $"api/v1/core/workflows/{workflow}/instances/{instanceId}/functions/{function}" + + (query is null ? "" : "?" + query); + + var (status, body) = await SendRawAsync(HttpMethod.Get, url, headers: Headers(roles)); + return (status, Parse(body)); + } + + /// The instance-scoped routes that are not `functions/…` but carry the same gate. + protected async Task<(HttpStatusCode Status, JsonElement Body)> CallInstanceRouteAsync( + string workflow, string instanceId, string route, string? roles) + { + var url = $"api/v1/core/workflows/{workflow}/instances/{instanceId}/{route}"; + var (status, body) = await SendRawAsync(HttpMethod.Get, url, headers: Headers(roles)); + return (status, Parse(body)); + } + + protected static JsonElement Parse(string body) => + string.IsNullOrWhiteSpace(body) ? default : JsonDocument.Parse(body).RootElement.Clone(); + + /// One level of an assembled chain. + protected sealed record ChainInstances(string RootWorkflow, string RootId, string MidId, string LeafId); +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainConjunctionTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainConjunctionTests.cs new file mode 100644 index 0000000..4c1c75a --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainConjunctionTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// authorize?queryRoles=true must answer the CONJUNCTION of the polled instance's own +/// queryRoles and every level beneath it, down to the deepest active leaf — and each level's +/// grants must resolve from the map its DIRECT parent stamped on it, never from an ancestor's. +/// +/// +/// Why this exists. Before the 2026-09-22 authorization change, authorize +/// short-circuited into the active SubFlow and never evaluated the polled instance's own grants. That +/// made it strictly WEAKER than the gate it exists to describe: the state function gates the polled +/// instance and then descends, where the leaf gates again — two conjunctive gates. A middle tier +/// trusting authorize would therefore admit reads the runtime itself refuses. +/// The chain is built so each role fails at exactly one level, which is what lets a wrong +/// verdict name its own cause rather than just being red. +/// +public sealed class ChainConjunctionTests : AuthorizationChainLabTestBase +{ + public ChainConjunctionTests(VNextTestEnvironment environment) : base(environment) { } + + /// + /// The defect itself. chain.reader passes the ROOT's own allowlist and fails the root's + /// override of the mid. Answering from the leaf alone — or from the root alone — would say + /// allowed; only the conjunction says denied. + /// + [Fact] + public async Task ReaderPassesTheRootAndFailsTheChain() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Root, chain.RootId, Reader, queryRoles: true), + "reader passes the root's own queryRoles but not the root's override of the mid; " + + "an allowed verdict here means the descent was skipped or the root answered alone"); + } + + /// + /// The control: a role that passes EVERY level must be allowed at every level. Without it a + /// blanket-deny bug would make the test above green for the wrong reason. + /// + /// chain.admin is the one role the fixture lets through end to end — the root's own grants, + /// the root's override of the mid, and the mid's override of the leaf all name it. + /// + /// + [Fact] + public async Task ARoleThatPassesEveryLevelIsAllowedEverywhere() + { + var chain = await StartChainAsync(); + + Assert.True(await IsAuthorizedAsync(Root, chain.RootId, Admin, queryRoles: true)); + Assert.True(await IsAuthorizedAsync(Mid, chain.MidId, Admin, queryRoles: true)); + Assert.True(await IsAuthorizedAsync(Leaf, chain.LeafId, Admin, queryRoles: true)); + } + + /// + /// authorize and the state function must agree question-for-question — on an instance + /// with NO active SubFlow of its own, which is where both gates resolve the same grants. + /// + /// + /// Restricted to the leaf deliberately, and the restriction is the finding. At the + /// root and the mid the two surfaces do NOT agree today, and it is not this change that parted + /// them: IsQueryAllowedAsync keys on EffectiveState, which for a parent is a + /// descendant's state key, so neither that level's own state queryRoles nor the parent's + /// stamped override can match and the gate degrades to the workflow root's grants. Measured: + /// chain.reader reads the root 200 while authorize — which does reach the + /// leaf — answers 403. + /// Asserting agreement at the root would therefore pin the defect rather than the contract. + /// It is recorded as a known gap in TEST-SCENARIOS.md and belongs to the follow-up that + /// fixes the keying, not here. + /// + [Theory] + [InlineData(Reader)] + [InlineData(Admin)] + [InlineData(LeafAdmin)] + [InlineData(MidAdmin)] + [InlineData(null)] + public async Task AuthorizeAgreesWithTheStateFunctionWhereBothResolveTheSameGrants(string? roles) + { + var chain = await StartChainAsync(); + + var authorized = await IsAuthorizedAsync(Leaf, chain.LeafId, roles, queryRoles: true); + var (stateStatus, _) = await CallFunctionAsync(Leaf, chain.LeafId, "state", roles); + + var stateAllowed = stateStatus switch + { + HttpStatusCode.OK => true, + HttpStatusCode.NotModified => true, + HttpStatusCode.Forbidden => false, + _ => throw new Xunit.Sdk.XunitException( + $"the state function answered {(int)stateStatus}, which is neither a read nor a refusal") + }; + + // The runtime no longer refuses here, so agreement is no longer the contract — the + // separation is. `authorize` keeps its own verdict (asserted by + // EnforcementPostureTests.AuthorizeKeepsRefusing) while the read surface serves; a state + // function that still answered 403 would mean a gate survived the removal. + Assert.True(stateAllowed, + "the read surface must not refuse on queryRoles — that decision belongs to the gateway"); + } + + /// + /// A role that only passes deeper down must still be refused at the top. This is the direction + /// the conjunction protects that a leaf-only answer would not: chain.leaf-admin satisfies + /// the mid's override of the leaf, and satisfies nothing at the root. + /// + [Fact] + public async Task ALeafOnlyRoleIsRefusedAtTheRoot() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Root, chain.RootId, LeafAdmin, queryRoles: true), + "chain.leaf-admin is absent from the root's allowlist; passing deeper down cannot buy " + + "access to a level it was never granted"); + } + + /// A caller with no roles at all is refused by an allowlist at every level. + [Fact] + public async Task ARoleLessCallerIsRefused() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Root, chain.RootId, NoRole, queryRoles: true)); + Assert.False(await IsAuthorizedAsync(Mid, chain.MidId, NoRole, queryRoles: true)); + Assert.False(await IsAuthorizedAsync(Leaf, chain.LeafId, NoRole, queryRoles: true)); + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainOverrideTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainOverrideTests.cs new file mode 100644 index 0000000..0bd7a4e --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/ChainOverrideTests.cs @@ -0,0 +1,130 @@ +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// A parent's subflow override governs its DIRECT child and nothing further, and it REPLACES that +/// child's own queryRoles rather than merging with them. +/// +/// +/// Why this exists. The overrides used to be read from the parent's definition at the +/// point of descent. That had two consequences: the descent RETURNED as soon as an override matched, +/// so a grandchild's own gate never ran; and at a directly addressed leaf — where no parent is in +/// scope — the reader found nothing and answered from the child's own grants, giving the OPPOSITE +/// verdict to the state function for the same instance. They are now resolved per hop from the map +/// stamped on each child at start. +/// This lab found a second defect on its first run, and it is fixed. +/// IsQueryAllowedAsync used to key the lookup on EffectiveState, which for an instance +/// that itself has an active SubFlow is a DESCENDANT's state key — so the stamped override (keyed by +/// the state the parent declared) could not match and the gate degraded to the workflow root's +/// grants. Measured on the bench: CurrentState=mid-waiting, EffectiveState=leaf-waiting on the +/// mid, and chain.mid-only read the mid 200 although the root had narrowed it to +/// chain.admin. A parent's narrowing silently stopped applying the moment its child opened a +/// subflow of its own. The gate now reads CurrentState. +/// Why the REPLACE tests still use a two-level chain. Not because the mechanism is +/// unreachable deeper — it is, now — but because at a mid level the conjunction down to the leaf +/// dominates every verdict, so an override difference there cannot be observed in isolation. The +/// two-level pair (same terminal child, one root overriding it and one not) is the smallest shape +/// that separates "override replaces" from "no override → own grants". +/// +public sealed class ChainOverrideTests : AuthorizationChainLabTestBase +{ + public ChainOverrideTests(VNextTestEnvironment environment) : base(environment) { } + + /// + /// An ancestor's override must not travel past its direct child. chain.mid-admin is named + /// by the ROOT's override of the mid and is absent from the MID's override of the leaf; admitting + /// it at the leaf would mean the root's narrowing had been carried one level too far. + /// + [Fact] + public async Task AnAncestorsOverrideDoesNotReachTheGrandchild() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Leaf, chain.LeafId, MidAdmin, queryRoles: true), + "chain.mid-admin is granted by the ROOT's override of the mid and by nothing at the leaf; " + + "an allowed verdict means an ancestor's override propagated past its direct child"); + } + + /// + /// The direct parent's override IS the one that governs, and it is applied at the child. + /// chain.leaf-admin appears only in the mid's override of the leaf — not in the leaf's own + /// grants, not at the root. + /// + [Fact] + public async Task TheDirectParentsOverrideGovernsTheChild() + { + var chain = await StartChainAsync(); + + Assert.True(await IsAuthorizedAsync(Leaf, chain.LeafId, LeafAdmin, queryRoles: true), + "chain.leaf-admin appears ONLY in the mid's override of the leaf — an allowed verdict " + + "proves the stamped override was read at the level it governs"); + } + + /// + /// The override REPLACES, it does not merge. chain.leaf-only is in the leaf's OWN grants + /// and absent from the mid's override; merging would let it back in, handing the narrowing + /// straight back to the roles it was taken from. + /// + [Fact] + public async Task TheOverrideReplacesTheChildsOwnGrants() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Leaf, chain.LeafId, "chain.leaf-only", queryRoles: true), + "chain.leaf-only is in the leaf's OWN queryRoles; the override replaces them, so an " + + "allowed verdict means the two grant sets were merged"); + } + + /// + /// The A/B pair, on a child with nothing beneath it so the mechanism is reachable. The same + /// terminal mid is started by two roots — one that overrides it and one that does not — and + /// chain.mid-only (in the child's own grants, absent from the override) separates them. + /// + [Fact] + public async Task WithNoOverrideTheChildsOwnGrantsApply() + { + var (_, plainChild) = await StartTwoLevelAsync(RootPlain); + + Assert.True(await IsAuthorizedAsync(MidTerminal, plainChild, MidOnly, queryRoles: true), + "root-plain declares no override, so the child's own queryRoles govern it"); + } + + /// The other half of the same pair: with an override declared, the child's own grants go. + [Fact] + public async Task WithAnOverrideTheChildsOwnGrantsAreReplaced() + { + var (_, narrowChild) = await StartTwoLevelAsync(RootNarrow); + + Assert.False(await IsAuthorizedAsync(MidTerminal, narrowChild, MidOnly, queryRoles: true), + "root-narrow overrides the child to chain.admin; chain.mid-only lives only in the " + + "child's own grants, which the override replaces"); + + Assert.True(await IsAuthorizedAsync(MidTerminal, narrowChild, Admin, queryRoles: true), + "and the role the override DOES name is admitted"); + } + + /// + /// The addressability property: a child reached directly answers exactly as the state function + /// answers there. Both go through the same stamped map. This is the case that previously produced + /// opposite verdicts. + /// + [Theory] + [InlineData(LeafAdmin)] + [InlineData(Admin)] + [InlineData("chain.leaf-only")] + [InlineData(MidAdmin)] + public async Task ADirectlyAddressedLeafAnswersTheSameAsItsStateFunction(string roles) + { + var chain = await StartChainAsync(); + + var authorized = await IsAuthorizedAsync(Leaf, chain.LeafId, roles, queryRoles: true); + var (stateStatus, _) = await CallFunctionAsync(Leaf, chain.LeafId, "state", roles); + + // Only the oracle's side is assertable now — the read surface no longer refuses at all. + // `authorized` is still the value under test: what this row pins is that it is computed from + // the map stamped on the leaf, so a directly addressed leaf answers as it would through its + // parent. The state call stays as a guard that no gate survived the removal. + Assert.NotEqual(System.Net.HttpStatusCode.Forbidden, stateStatus); + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/DataDescentAsymmetryTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/DataDescentAsymmetryTests.cs new file mode 100644 index 0000000..f9dd111 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/DataDescentAsymmetryTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// data is the one surface whose CONTENT does not descend while its AUTHORIZATION does. +/// +/// +/// Why this exists. Every built-in instance function walks into an active SubFlow — +/// state, view, schema, master, extensions and authorize — +/// except data, which serves the polled instance's own attributes. The requester settled the +/// remaining half on 2026-09-22: the data function's execution side is correct and must not change, +/// and its authorization descends with everything else, because while the instance sits in an active +/// subflow the subflow's rules are the ones in force. +/// The rule, in one line: authorization follows where the instance actually is; content +/// follows what the client holds. That reads as an inconsistency to anyone meeting it for the +/// first time, which is exactly why it is pinned here rather than left to a comment. +/// This pair is the most likely thing to be "simplified" later by someone who reads only one +/// half of it, so both halves are asserted in the same test. +/// +public sealed class DataDescentAsymmetryTests : AuthorizationChainLabTestBase +{ + public DataDescentAsymmetryTests(VNextTestEnvironment environment) : base(environment) { } + + /// + /// The half that is provable today: data serves the POLLED instance's attributes, never a + /// descendant's. + /// + /// + /// The other half — "its authorization descends" — is a property of the authorize FUNCTION, + /// which the middle tier consults, not of the data function's own gate. The data function gates + /// the polled instance only, exactly as the state function does, and both fall back to the + /// workflow root's grants while a SubFlow is active (the EffectiveState keying defect). + /// An earlier version of this test asserted 403 for chain.reader at the root and was + /// conflating the two; the agreement that IS enforceable today is pinned by + /// . + /// + [Fact] + public async Task DataServesThePolledInstancesOwnAttributes() + { + var chain = await StartChainAsync(); + + var (adminStatus, adminBody) = await CallFunctionAsync(Root, chain.RootId, "data", Admin); + Assert.Equal(HttpStatusCode.OK, adminStatus); + + // The data function answers an envelope whose attributes live under `data` + // ({"data":{…},"eTag":…,"extensions":{}}), not under `attributes` — measured, not assumed. + var attributes = adminBody.ValueKind == System.Text.Json.JsonValueKind.Object + && adminBody.TryGetProperty("data", out var inner) + ? inner + : adminBody; + + Assert.True( + attributes.ValueKind == System.Text.Json.JsonValueKind.Object + && attributes.TryGetProperty("chainRef", out _), + "the data function must serve the POLLED instance's attributes — chainRef is written at " + + "the root's start and never copied down the chain, so its absence means data descended"); + } + + /// + /// data and state must agree about admission even though they disagree about whose + /// content they serve. A gateway has one queryRoles verdict for the whole read family; if + /// these two diverged, that single verdict could not be right for both. + /// + [Theory] + [InlineData(Reader)] + [InlineData(Admin)] + [InlineData(LeafAdmin)] + public async Task DataAndStateAgreeAboutAdmission(string roles) + { + var chain = await StartChainAsync(); + + var (dataStatus, _) = await CallFunctionAsync(Root, chain.RootId, "data", roles); + var (stateStatus, _) = await CallFunctionAsync(Root, chain.RootId, "state", roles); + + Assert.Equal(dataStatus == HttpStatusCode.Forbidden, stateStatus == HttpStatusCode.Forbidden); + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/EnforcementPostureTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/EnforcementPostureTests.cs new file mode 100644 index 0000000..60a41e4 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/EnforcementPostureTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// The removal: the runtime's own queryRoles and long-poll acknowledge gates are gone from +/// every surface that carried them, while authorize — the oracle the Internal Gateway +/// consults — keeps answering exactly as before. +/// +/// +/// What moved and what did not. Authorization for these surfaces now happens at the +/// Internal Gateway, which asks GET .../functions/authorize?queryRoles=true (and +/// ?ack=true) before the request reaches the runtime. `queryRoles` itself is untouched: it is +/// still evaluated in full, per hop down the active-correlation chain, by that endpoint. What was +/// deleted is the runtime's SECOND copy of the decision — not the decision. +/// Why the oracle test is the important one here. A removal is easy to see in a diff; +/// what is not easy to see is a removal that went one surface too far. If authorize ever +/// stopped refusing, the gateway would be admitting on an answer that always says yes, and nothing +/// downstream would notice — so that assertion is the one this file exists for. +/// And role RESOLUTION is not enforcement. Discovery filtering, state aliases, +/// x-roles field pruning and the caller-scoped caches all still need the caller's roles. Only +/// the 403 left. +/// +public sealed class EnforcementPostureTests : AuthorizationChainLabTestBase +{ + public EnforcementPostureTests(VNextTestEnvironment environment) : base(environment) { } + + /// + /// Every formerly gated read surface, addressed on the LEAF with a role the leaf's effective + /// grants exclude (chain.leaf-only is replaced away by the mid's override). None of them + /// may refuse any more. + /// + /// + /// The assertion is "not 403" rather than "200" on purpose: several of these legitimately answer + /// 400 or 404 for their own reasons — actions needs a task that exists, + /// incidents/active 404s when none is open — and conflating those with a refusal is exactly + /// the mistake that would make this row prove nothing. + /// + [Theory] + [InlineData("functions/state")] + [InlineData("functions/data")] + [InlineData("functions/view")] + [InlineData("functions/schema")] + [InlineData("functions/master")] + [InlineData("functions/tasks")] + [InlineData("functions/actions?taskId=00000000-0000-0000-0000-000000000001")] + [InlineData("incidents")] + [InlineData("incidents/active")] + public async Task NoReadSurfaceRefusesOnQueryRoles(string route) + { + var chain = await StartChainAsync(); + + // `actions` carries a taskId because without one the request never reaches the gate — it is + // rejected as a client error first, which would make this row prove nothing either way. + var (status, _) = await CallInstanceRouteAsync(Leaf, chain.LeafId, route, "chain.leaf-only"); + + Assert.NotEqual(HttpStatusCode.Forbidden, status); + } + + /// + /// The tenth surface: the acknowledge endpoint, the only state-changing one. It no longer + /// refuses either. + /// + /// + /// Its pre-flight, authorize?ack=true, remains and still admits through the very + /// same ILongPollInteractionGate — which is what makes handing this decision to the + /// gateway possible at all, because the interaction's rule arm is a C# script no gateway + /// can evaluate itself. The gate is still in service; only its caller changed. + /// The assertion is "not 403" rather than a fixed status because whether the leaf is still + /// awaiting an acknowledgement when the call lands depends on the fallback timer. Measured while + /// writing this: the leaf reported status: A and the endpoint answered 200 + /// idempotently. + /// + [Fact] + public async Task TheAcknowledgeEndpointDoesNotRefuse() + { + var chain = await StartChainAsync(); + + var (status, _) = await SendRawAsync( + HttpMethod.Post, + $"api/v1/core/workflows/{Leaf}/instances/{chain.LeafId}/longpoll/ack", + headers: Headers(LeafAdmin)); + + Assert.NotEqual(HttpStatusCode.Forbidden, status); + } + + /// + /// authorize keeps refusing — it is the answer the Internal Gateway admits on, not a gate + /// on this runtime's own path. If the removal had reached it, the gateway would be consulting an + /// oracle that says yes to everything, and nothing downstream would notice. + /// + [Fact] + public async Task AuthorizeKeepsRefusing() + { + var chain = await StartChainAsync(); + + Assert.False(await IsAuthorizedAsync(Leaf, chain.LeafId, "chain.leaf-only", queryRoles: true), + "authorize is the oracle, not a gate — the enforcement switch must not touch it"); + } + + /// + /// Role RESOLUTION must survive the removal. This is the regression the change is most likely to + /// cause: deleting the gate and the role lookup together would leave discovery filtering, x-roles + /// pruning and the caller-scoped caches silently unkeyed. + /// + [Fact] + public async Task DiscoveryFilteringStillDependsOnTheCallersRoles() + { + var chain = await StartChainAsync(); + + var (adminStatus, adminBody) = await CallFunctionAsync(Root, chain.RootId, "state", Admin); + Assert.Equal(HttpStatusCode.OK, adminStatus); + + var adminKeys = TransitionKeys(adminBody); + Assert.Contains("record-note", adminKeys); + + // `record-note` grants chain.reader and chain.admin only. A caller outside that set must not + // be offered it — this is visibility, which the runtime still owns; enforcement is what left. + var (otherStatus, otherBody) = await CallFunctionAsync(Root, chain.RootId, "state", LeafAdmin); + + if (otherStatus == HttpStatusCode.OK) + Assert.DoesNotContain("record-note", TransitionKeys(otherBody)); + } + + private static IReadOnlyList TransitionKeys(System.Text.Json.JsonElement body) + { + var keys = new List(); + if (body.ValueKind != System.Text.Json.JsonValueKind.Object) return keys; + if (!body.TryGetProperty("transitions", out var transitions)) return keys; + + foreach (var transition in transitions.EnumerateArray()) + { + if (transition.TryGetProperty("kind", out var kind) && kind.GetString() == "scheduled") + continue; + if (transition.TryGetProperty("name", out var name) && name.GetString() is { } key) + keys.Add(key); + } + + return keys; + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/MorphIdmProviderTests.cs b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/MorphIdmProviderTests.cs new file mode 100644 index 0000000..abfa8c7 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/MorphIdmProviderTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.AuthorizationChainLab; + +/// +/// The morph-idm caller-role provider, end to end: the roles every authorization decision is +/// made against come from the identity service, and from nowhere else. +/// +/// +/// Why a separate run. The provider is chosen once at startup +/// (CallerRoleProvider:Provider) and never varies per request, so it cannot be exercised +/// inside the suite's ordinary run. These tests are skipped unless +/// VNEXT_CALLER_ROLE_PROVIDER=morph-idm says the runtime under test was started that way — +/// silently passing against a default-provider runtime would be worse than not running at +/// all, since they would then prove nothing while looking green. +/// What the MockLab seed gives them. morph-idm-roles-collection.json answers +/// GET api/1/morph-idm/functions/get-roles, keyed on the user_reference header the +/// resolver forwards. Four users carry the assertions — idm-admin (a role that passes the +/// whole chain), idm-leaf-only (one that does not), idm-empty (204) and +/// idm-broken (500) — and any other user gets a working default set so the chain can +/// still be assembled. +/// What is NOT re-proved here. The conjunction, the overrides and the switch are +/// provider-independent: they consume a role set, they do not decide where it came from. Repeating +/// all 44 under a second provider would cost a full run to re-measure something already measured. +/// What IS provider-specific is which set arrives — and that is the whole subject below. +/// +public sealed class MorphIdmProviderTests : AuthorizationChainLabTestBase +{ + public MorphIdmProviderTests(VNextTestEnvironment environment) : base(environment) { } + + private const string AdminUser = "idm-admin"; + private const string LeafOnlyUser = "idm-leaf-only"; + private const string EmptyUser = "idm-empty"; + private const string BrokenUser = "idm-broken"; + + /// + /// True only when the runtime under test was started with the morph-idm provider. Set it in the + /// same command that starts that runtime. + /// + private static bool ProviderIsMorphIdm => + string.Equals( + System.Environment.GetEnvironmentVariable("VNEXT_CALLER_ROLE_PROVIDER"), + "morph-idm", + System.StringComparison.OrdinalIgnoreCase); + + private static Dictionary As(string user) => new() { ["sub"] = user }; + + /// + /// The decisive one. A caller ASSERTING chain.admin in the role/x-roles + /// headers, whose identity service answers "no operations", must be refused. + /// + /// + /// This is the property the whole provider exists for. If the header survived as a fallback — + /// through a merge, or through an empty answer being read as "nothing to say, use what you have" + /// — then a caller could name its own roles, and every grant in every definition would be + /// advisory. 204 is the exact shape that invites the mistake: it is a successful response + /// carrying no roles, and treating it as absence rather than as an empty set restores the header. + /// + [SkippableFact] + public async Task AnAssertedHeaderRoleDoesNotSurviveAnEmptyProviderAnswer() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + Assert.False( + await IsAuthorizedAsync(Root, chain.RootId, Admin, queryRoles: true, extraHeaders: As(EmptyUser)), + "the caller named chain.admin in its own headers and morph-idm answered 204 (no " + + "operations); an allowed verdict means the header was used as a fallback"); + } + + /// + /// The same rule on the OTHER channel: the role query parameter. A caller whose + /// identity service reports no operations must not be able to name its own role in the query + /// string either. + /// + /// + /// This one was found by probing rather than by reading, after the header case was already + /// closed and green. `authorize` fell back to the parameter whenever the provider returned an + /// empty set, with no regard for which provider had returned it. Measured on this lab, same caller + /// and same instance: + /// + /// ?queryRoles=true -> {"allowed":false} 403 + /// ?queryRoles=true&role=chain.admin -> {"allowed":true} 200 + /// + /// It matters more than it looks: since the runtime's own gates were removed, `authorize` is + /// the only place these questions are answered, so a gateway that forwards the client's query + /// string would have been admitting on the client's own claim. The fix is a capability on the + /// resolver (ICallerRoleResolver.AllowsRoleParameterFallback) — false for any provider that + /// is an authority — rather than a provider-name check inside authorize. + /// + [SkippableFact] + public async Task TheRoleQueryParameterDoesNotSurviveAnEmptyProviderAnswer() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + Assert.False( + await IsAuthorizedAsync(Root, chain.RootId, NoRole, queryRoles: true, + extraHeaders: As(EmptyUser), roleParameter: Admin), + "morph-idm answered 204 for this caller; naming chain.admin in the query string must not " + + "override that — it is the header hole reached through a different channel"); + } + + /// + /// And the parameter is not merely ignored for queryRoles: ack composes it + /// ADDITIVELY, which would otherwise leave it as the one target where a caller still names its own + /// role. + /// + [SkippableFact] + public async Task TheRoleQueryParameterDoesNotReachTheAckPreflightEither() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + // Nothing is awaiting, so the honest assertion here is agreement with the endpoint rather than + // a fixed verdict: both answer "allowed" idempotently. What is pinned is that adding the + // parameter does not CHANGE the answer. + var withoutParameter = await IsAuthorizedAsync( + Root, chain.RootId, NoRole, ack: true, extraHeaders: As(EmptyUser)); + var withParameter = await IsAuthorizedAsync( + Root, chain.RootId, NoRole, ack: true, extraHeaders: As(EmptyUser), roleParameter: Admin); + + Assert.Equal(withoutParameter, withParameter); + } + + /// + /// The other direction: no role header at all, and the identity service supplies the grant. Proves + /// the answer is actually consumed rather than the header merely being ignored. + /// + [SkippableFact] + public async Task TheProvidersAnswerGrantsWithNoRoleHeaderAtAll() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + Assert.True( + await IsAuthorizedAsync(Root, chain.RootId, NoRole, queryRoles: true, extraHeaders: As(AdminUser)), + "morph-idm answers chain.admin for this user and the caller sent no role header; a " + + "refusal means the provider's answer never reached the grant evaluation"); + } + + /// + /// The chain still composes under this provider: a role that clears the root but not the levels + /// beneath it is refused, exactly as it is under the default provider. + /// + [SkippableFact] + public async Task TheConjunctionStillHoldsOnProviderSuppliedRoles() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + Assert.False( + await IsAuthorizedAsync(Root, chain.RootId, NoRole, queryRoles: true, extraHeaders: As(LeafOnlyUser)), + "chain.leaf-only is granted by the leaf and by no level above it"); + } + + /// + /// A provider that cannot answer must refuse, not degrade to an empty role set. + /// + /// + /// Empty and unknown look the same one line later and mean opposite things. An unreachable + /// identity service would, under the permissive reading, turn every allowlist into a silent + /// blanket denial and every blacklist into a silent blanket ALLOW — the second of which is an + /// outage that grants access. The refusal is explicit instead: 403 with + /// Authorization:CallerRoleResolutionFailed. + /// + [SkippableFact] + public async Task AnUnreachableProviderRefusesRatherThanResolvingToNoRoles() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + var (status, _) = await AuthorizeAsync( + Root, chain.RootId, Admin, queryRoles: true, extraHeaders: As(BrokenUser)); + + Assert.Equal(HttpStatusCode.Forbidden, status); + } + + /// + /// The read surfaces resolve caller roles through the SAME provider as authorize. They no + /// longer refuse anyone, but they still decide what a caller is OFFERED — and that decision has to + /// be about the same caller the gateway's oracle was asked about. + /// + /// + /// This replaces an earlier assertion that the state function answers 403 for a caller + /// morph-idm reports no operations for. That premise is gone with the gate: the read is served + /// either way. What remains observable — and is the real risk under a provider change — is role + /// RESOLUTION: if the read path fell back to the role header here while authorize + /// asked the identity service, the two would be describing different callers and the gateway's + /// verdict would be attached to the wrong request. + /// + [SkippableFact] + public async Task TheReadSurfacesResolveRolesThroughTheSameProvider() + { + Skip.IfNot(ProviderIsMorphIdm, "runtime is not running the morph-idm provider"); + var chain = await StartChainAsync(); + + // morph-idm answers chain.admin for this user, and the caller sends no role header at all. + var (withRoles, granted) = await SendRawAsync(HttpMethod.Get, + $"api/v1/core/workflows/{Root}/instances/{chain.RootId}/functions/state", + headers: Merge(Headers(NoRole), As(AdminUser))); + + Assert.Equal(HttpStatusCode.OK, withRoles); + Assert.Contains("record-note", TransitionKeys(Parse(granted))); + + // Same instance, and the caller now ASSERTS chain.admin in its own headers — but morph-idm + // answers 204 for it. The header must not put the transition back. + var (withoutRoles, empty) = await SendRawAsync(HttpMethod.Get, + $"api/v1/core/workflows/{Root}/instances/{chain.RootId}/functions/state", + headers: Merge(Headers(Admin), As(EmptyUser))); + + Assert.Equal(HttpStatusCode.OK, withoutRoles); + Assert.DoesNotContain("record-note", TransitionKeys(Parse(empty))); + } + + private static IReadOnlyList TransitionKeys(System.Text.Json.JsonElement body) + { + var keys = new List(); + if (body.ValueKind != System.Text.Json.JsonValueKind.Object) return keys; + if (!body.TryGetProperty("transitions", out var transitions)) return keys; + + foreach (var transition in transitions.EnumerateArray()) + { + if (transition.TryGetProperty("kind", out var kind) && kind.GetString() == "scheduled") + continue; + if (transition.TryGetProperty("name", out var name) && name.GetString() is { } key) + keys.Add(key); + } + + return keys; + } + + private static Dictionary Merge( + Dictionary baseHeaders, Dictionary extra) + { + foreach (var (k, v) in extra) baseHeaders[k] = v; + return baseHeaders; + } +} diff --git a/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/README.md b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/README.md new file mode 100644 index 0000000..5211bcb --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/AuthorizationChainLab/README.md @@ -0,0 +1,184 @@ +# authorization-chain-lab + +## Ne kontrol ediyor + +`authorize` fonksiyonunun bir **aktif korelasyon zinciri** boyunca verdiği cevabın, okuma +yüzeylerinin fiilen uyguladığı kapıyla aynı olduğunu — ve parent'ın subflow override'larının +**hop başına**, çocuğa damgalanmış haritadan çözüldüğünü. + +## Neden var + +2026-09-22 konseyi (`DECISION-2026-09-22-remove-execution-authorization`) `authorize`'da üç canlı +kusur buldu. Üçü de aynı sonuca çıkıyordu: **ara katmanın danıştığı cevap, runtime'ın uyguladığı +kapıdan farklıydı.** + +1. **Konjonksiyon yoktu.** `authorize?queryRoles=true` aktif subflow'a kısa devre yapıp poll edilen + instance'ın kendi `queryRoles`'unu **hiç değerlendirmiyordu**. State function ise kökte gate'leyip + sonra iniyor ve leaf'te tekrar gate'leniyor — iki konjonktif kapı. Yani `authorize`, tarif etmesi + gereken kapıdan **zayıftı**: ona güvenen bir gateway, runtime'ın reddettiği okumayı kabul ederdi. +2. **Override eşleşince iniş duruyordu.** Parent'ın tanımındaki override derinlik 1'de `return` + ediyordu, dolayısıyla torunun kendi kapısı hiç çalışmıyordu. +3. **Override yanlış yerden okunuyordu.** Parent'ın tanımından okumak, **doğrudan adreslenen** bir + leaf'te (parent kapsamda değilken) hiçbir şey bulamıyor ve çocuğun kendi grant'larına düşüyordu — + aynı instance için `authorize` ile state function **zıt** verdikt veriyordu. + +Ayrıca bu suite, aynı değişiklikle gelen iki şeyi sabitliyor: `authorize`'ın dördüncü hedefi +**`ack`** (long-poll acknowledge ön-kontrolü, ara katmanın başka türlü soramadığı tek +durum-değiştiren yüzey) ve runtime'ın kendi `queryRoles` / ack **kapılarının kaldırılmış olması** — +o karar artık Internal Gateway'in, `authorize`'a sorarak verdiği karardır. + +## Neden ayrı bir akış + +`subflow-orchestration` zaten A→B→C bir zincir ve A'nın B için bir `overrides.states` girdisi var. +Ama o suite **yeşil** ve testleri rol header'ı göndermeden okuyor; ara/yaprak seviyelere `queryRoles` +eklemek onu kırardı. Bu lab aynı zincir şekline sahip bağımsız bir kopya. + +## Zincir ve grant tasarımı + +``` +ROOT (F) waiting --subFlow--> MID (S) mid-waiting --subFlow--> LEAF (S) leaf-waiting +``` + +| Seviye | Kendi `queryRoles` | Üstündeki override | +|---|---|---| +| ROOT | `chain.reader`, `chain.admin` | — | +| MID | `chain.reader`, `chain.admin`, `chain.mid-only` | ROOT → `chain.admin` | +| LEAF | `chain.reader`, `chain.admin`, `chain.leaf-only` | MID → `chain.leaf-admin` | + +Her rol **tam olarak bir seviyede** düşecek şekilde seçildi; böylece yanlış bir verdikt kendi +sebebini söylüyor: + +| Rol | ROOT | ROOT→MID override | MID→LEAF override | +|---|---|---|---| +| `chain.reader` | ✅ | ❌ | — | +| `chain.admin` | ✅ | ✅ | ❌ | +| `chain.leaf-admin` | ❌ | — | ✅ | +| `chain.mid-only` | ❌ | — | — (yalnız `root-plain` üzerinden görünür) | + +`authorization-chain-lab-root-plain` aynı MID'i **override bildirmeden** başlatır: "override yoksa +nesnenin kendi tanımı uygulanır" yarısının kontrol grubu. Onsuz REPLACE iddiası tek yönlü kalırdı. + +**Kritik adım:** zincir otomatik kurulur (her seviyenin initial state'inde `triggerType: 1` bir hop). +Test yalnızca root'u başlatır ve korelasyon haritası iki seviye derinleşene kadar bekler. + +## Nasıl koşulur + +Ön koşullar: infra + runtime ayakta (`etc/docker/run-docker.sh up core`), `VNEXT_BASE_URL` +`test.runsettings` içinde doğru. MockLab **gerekmez** — bu lab HTTP task çağırmıyor. + +```bash +cd vnext-example +dotnet test tests/Core.IntegrationTests --settings tests/Core.IntegrationTests/test.runsettings \ + --filter "FullyQualifiedName~AuthorizationChainLab" -v minimal +``` + +### Kapılar kaldırıldı — ölçülen şey artık "reddetmiyor" + +Runtime, `queryRoles`'u okuma yüzeylerinde (`state`, `data`, `view`, `schema`, `master`, `tasks`, +`actions`, `incidents`, `incidents/active`) ve `interaction.longPoll` kapısını `POST .../longpoll/ack` +üzerinde **artık uygulamıyor**. `EnforcementPostureTests` bunu yüzey yüzey doğruluyor: hiçbiri 403 +dönmüyor ve hiçbiri kapıya sormuyor. + +**`queryRoles` kaldırılmadı, yeri değişti.** Hâlâ tam olarak, aktif korelasyon zinciri boyunca hop +başına değerlendiriliyor — ama `GET .../functions/authorize?queryRoles=true` tarafından. Silinen şey, +runtime'ın aynı kararın **ikinci** kopyası. Bu suite'in geri kalan 40 testi zaten o cevabın doğruluğunu +ölçüyor; bu bölüm yalnızca ikinci kopyanın gitmiş olduğunu ölçüyor. + +Bu yüzden `AuthorizeKeepsRefusing` buradaki en önemli satır: bir kaldırmanın bir yüzey fazla +gitmiş olduğunu diff'te görmek zordur, ve `authorize` reddetmeyi bıraksaydı gateway her şeye "evet" +diyen bir kâhine danışıyor olurdu. + +## Geçme kriteri + +44 testin tamamı yeşil. En ayırt edici üçü: + +- `ChainConjunctionTests.ReaderPassesTheRootAndFailsTheChain` — konjonksiyon yoksa **yeşil olamaz**. +- `ChainOverrideTests.AnAncestorsOverrideDoesNotReachTheGrandchild` — override aşağı taşınırsa kırmızı. +- `ChainOverrideTests.ADirectlyAddressedLeafAnswersTheSameAsItsStateFunction` — damgalı harita + yerine parent-taraflı okuyucu kullanılırsa kırmızı. + +## Koşu kaydı + +**2026-09-23 — 44/44.** Runtime lokal build (`claude/remove-authorize-checks-cc40e5`, master tabanı +`f7a053c8`), `VNEXT_BASE_URL=http://localhost:4201`. + +Suite önce bir **geçiş anahtarı** (`Workflow:Authorization:EnforceInProcess`) karşısında yazıldı ve +iki duruşta da koşuldu; sonra kullanıcı kararıyla kapılar **tamamen kaldırıldı** ve suite bu son hale +göre yeniden yazılıp tekrar koşuldu. Anahtarı ölçen testler, kaldırmayı ölçenlere dönüştü — artık +ortam değişkeni yok, tek duruş var. + +İlk koşu yeşil değildi ve bu laboratuvarın var olma sebebi o: **üç runtime kusuru buldu, üçü de +`authorize`'ı ara katmanın güvenemeyeceği hale getiriyordu.** + +1. **`queryRoles` kapısı `EffectiveState` okuyordu.** Kendi subflow'u olan bir ara seviyede bu bir + TORUNUN state anahtarıdır; parent'ın workflow tanımı onu çözemez, çocuğa damgalanmış override + (parent'ın beyan ettiği state ile anahtarlanmıştır) eşleşemez, ve kapı sessizce workflow kökünün + grant'larına düşer. Ölçüldü: mid'de `CurrentState=mid-waiting` / `EffectiveState=leaf-waiting`, + kök `chain.mid-only`'yi `chain.admin`'e daraltmışken o rol mid'i **200** okudu. Yani yazılmış bir + kısıt, çocuk kendi subflow'unu açtığı anda — hatasız, logsuz — uygulanmayı bırakıyordu. +2. **State transition'ları `availableIn` üzerinden state'e bakılıyordu.** O liste state + transition'larında boştur ve "boş = her state" kuralı onlar için yanlıştır: `review`'de tanımlı + `approve`, instance `intake`'te otururken de allowed görünüyordu. Execution bunu `Transition:100021` + ile reddediyor — yani oracle **permissive** yönde yanlıştı, ki ara katman onun cevabıyla kapı + açtığında önemli olan yön budur. +3. **`interaction` bloğu state'in BEYANINA göre yayınlanıyordu**, gerçekten bir ack beklenip + beklenmediğine göre değil. Endpoint bekleyen yokken idempotent `Ok()` döndüğü için hiçbir şey + gürültülü kırılmıyordu; istemci o state'in her poll'ünde bir ack atıp başarı okuyordu. + `ResponseShapeVersion` v10→v11. + +**2026-09-23 — `morph-idm` provider'ı: 7/7.** Orchestration host'u +`CallerRoleProvider__Provider=morph-idm` + MockLab (`morph-idm-roles-collection.json`) ile üçüncü kez +başlatıldı; sonra varsayılan provider'a geri alınıp 44'lük suite tekrar koşuldu (44 geçti, 5 atlandı +— provider testleri kendilerini `VNEXT_CALLER_ROLE_PROVIDER` ile kapatıyor, çünkü `default` altında +sessizce yeşil olmak hiç koşmamaktan kötüdür). Kapılar kaldırıldıktan sonra tekrarlandı: 5/5. + +**Bu koşu bir açık daha buldu — okuyarak değil, prob atarak.** Header kanalı kapatılıp yeşile +alındıktan sonra aynı etkinin **`role` query parametresinden** geçtiği görüldü: `authorize`, provider +boş küme döndüğünde parametreye düşüyordu ve bunu **hangi** provider'ın döndürdüğüne bakmıyordu. Yani +morph-idm'in `204`'ü ("bu çağıranın operasyonu yok"), çağıranın query string'e kendi rolünü yazmasıyla +eziliyordu. Aynı instance, aynı çağıran: + +``` +?queryRoles=true -> {"allowed":false} 403 +?queryRoles=true&role=chain.admin -> {"allowed":true} 200 ← düzeltmeden önce +``` + +`authorize` tek yetki noktası olduğundan bu, istemcinin query string'ini geçiren bir gateway'in +istemcinin kendi beyanına göre kapı açması demekti. Çözüm resolver üzerinde bir yetenek +(`ICallerRoleResolver.AllowsRoleParameterFallback`): kaynağı zaten çağıranın kendi beyanı olan +provider'da (`default`) parametre geçerli, otorite olan provider'da (`morph-idm`) tamamen yok sayılıyor — +`authorize` içinde provider adı kontrolü değil, çünkü yeni bir provider cevabı miras almak yerine +kendi cevabını beyan etmeli. `TheRoleQueryParameterDoesNotSurviveAnEmptyProviderAnswer` ve +`TheRoleQueryParameterDoesNotReachTheAckPreflightEither` bunu sabitliyor. + +Kaldırma, bu setteki bir testin **önermesini** yok etti: "morph-idm boş küme dönen çağrıyı state +fonksiyonu 403'ler" artık doğru değil, okuma her hâlükârda servis ediliyor. Yerine gözlenebilir kalan +şey konuldu — rol **çözümlemesi**: aynı instance'ta, header'ında `chain.admin` yazan ama morph-idm'in +`204` dediği çağrıya `record-note` **teklif edilmiyor**, hiç role header'ı olmayıp morph-idm'in +`chain.admin` dediği çağrıya **ediliyor**. Okuma yolu ile `authorize` aynı çağıranı tarif etmezse +gateway'in verdikti yanlış isteğe iliştirilmiş olurdu; ölçülen budur. + +Bu koşunun ilk denemesi de kırmızıydı, ama kusur **testin kendisindeydi**, runtime'da değil: kimlik +header'ı `user_reference` sanılmıştı; resolver `AetherClaimTypes.UserName` gönderir, o da **`sub`**'tır. +MockLab'in istek günlüğü bunu doğrudan gösterdi — giden istekte hiçbir kimlik header'ı yoktu, mock da +anahtarsız varsayılan cevabı döndü. Kayda değer yan gözlem: kimlik header'ı olmayan bir çağrıda +runtime yine de morph-idm'e **anonim** olarak soruyor; bu bir kusur değil (fallback zinciri öyle +tasarlanmış) ama provider tarafının o durumu ne döndürdüğü dağıtım başına kararlaştırılmalı. + +## Bilinen sınırlar + +- **`interaction.longPoll.rule` kolu kapsanmıyor, çünkü AUTHORLANAMIYOR.** Rule kolu runtime'a + issue #936 ile (0.0.92) girdi ama `@burgan-tech/vnext-schema` özelliği hiç almadı: kurulu 0.0.52 + **ve** sibling repodaki 1.0.0, `longPoll` için `required: [terminate, roles]` + + `additionalProperties: false` diyor. Rule kollu bir state `npm run validate`'den geçmiyor, yani + hiçbir domain ekibi yazamıyor. `vnext-meta/features.json` bunun tersini iddia ediyor ("the + vnext-schema longPoll contract enforces exactly one of roles|rule at authoring time") — **bu iddia + yanlış**. Ack'in rule-kolu pariteси şema özelliği gönderilene kadar unit testlerde kalıyor. +- **`morph-idm` provider'ı ile yalnız `MorphIdmProviderTests` koşuyor**, 44'ün tamamı değil — ve bu + bilinçli. Provider başlangıçta bir kez seçilir, istek başına değişmez; diğer 44 test rolleri + `x-roles` ile ayrıştırıyor, o header ise bu provider altında kararı vermiyor, dolayısıyla aynı + suite'i ikinci provider altında koşmak yalnızca her çağrıyı aynı varsayılan role kümesine + indirgerdi. Konjonksiyon ve override'lar zaten **provider'dan bağımsızdır**: bir rol + kümesini tüketirler, onun nereden geldiğine karar vermezler. Provider'a özgü olan **hangi kümenin + geldiğidir** ve ölçülen tam olarak odur. +- Bu bir **doğruluk** senaryosu; gecikme iddiası yok, Python yük testi bilinçli olarak yazılmadı. diff --git a/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryRegistryFixture.cs b/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryRegistryFixture.cs new file mode 100644 index 0000000..e6c8e60 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryRegistryFixture.cs @@ -0,0 +1,37 @@ +namespace Core.IntegrationTests.Tests.CrossDomainLab; + +/// +/// Holds the discovery registry's base url and a client for it, so the warm-up tests can read the +/// registry directly and compare it with what the runtime under test did with the same data. +/// +/// +/// Deliberately separate from and gated on its own environment +/// variable. The registry warm-up needs only core plus the registry — not the partner +/// domain — so it runs in a two-domain setup as well as in the full three-domain lab, and it must not +/// be skipped just because VNEXT_PARTNER_BASE_URL is absent. +/// +public sealed class DiscoveryRegistryFixture : IDisposable +{ + /// Discovery registry orchestrator base url, or null when no registry is configured. + public string? RegistryBaseUrl { get; } = + Environment.GetEnvironmentVariable("VNEXT_DISCOVERY_BASE_URL")?.Trim().TrimEnd('/') is { Length: > 0 } url + ? url + : null; + + /// The registry domain, which is also the first path segment of its function urls. + public string RegistryDomain { get; } = + Environment.GetEnvironmentVariable("VNEXT_DISCOVERY_DOMAIN")?.Trim() is { Length: > 0 } domain + ? domain + : "discovery"; + + private HttpClient? _client; + + /// Client on the registry. Only valid when is set. + public HttpClient Client => _client ??= new HttpClient + { + BaseAddress = new Uri((RegistryBaseUrl ?? throw new InvalidOperationException( + "registry client unavailable — VNEXT_DISCOVERY_BASE_URL is not set")) + "/") + }; + + public void Dispose() => _client?.Dispose(); +} diff --git a/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryWarmUpTests.cs b/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryWarmUpTests.cs new file mode 100644 index 0000000..0c8d56e --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/CrossDomainLab/DiscoveryWarmUpTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.CrossDomainLab; + +/// +/// AC-12..14 — the discovery endpoint cache's bulk warm-up reads the registry's domain-list +/// function and publishes what it reads. +/// +/// +/// +/// Why this exists. The runtime used to enumerate the registry by paging its raw instance +/// list (/{domain}/workflows/domain/instances?page=…) and re-deriving the domain projection +/// client-side — status filtering, key-versus-domainName fallback, page-stall detection, a +/// page cap. The registry now owns that projection in a Domain-scope, server-cached function, so the +/// runtime reads it once per refresh window instead (vnext, 2026-09-17). There is deliberately no +/// fallback to the old paged read, which makes "does the runtime actually speak to this function, +/// and does it do the right thing with the answer" the one question unit tests cannot settle: they +/// pin the parsing against a captured payload, not against a live registry. +/// +/// +/// What it needs. A discovery registry carrying @burgan-tech/vnext-discovery-runtime +/// >= 0.0.7 (the first version with domain-list) at VNEXT_DISCOVERY_BASE_URL, and a +/// core runtime configured against it with ServiceDiscovery__Enabled=true, +/// ServiceDiscovery__Provider=http and ServiceDiscovery__Cache__Enabled=true. The cache +/// is registered only for the http provider — the Dapr provider derives app-ids from a naming +/// convention and never reads the registry on its default path — so under +/// Provider=dapr the refresh endpoint answers disabled and AC-13/AC-14 skip +/// themselves rather than fail. +/// +/// +[Collection("VNextIntegration")] +public class DiscoveryWarmUpTests : WorkflowTestBase, IClassFixture +{ + private const string RefreshPath = "api/v1/utilities/discovery/refresh"; + + private readonly DiscoveryRegistryFixture _registry; + + public DiscoveryWarmUpTests(VNextTestEnvironment environment, DiscoveryRegistryFixture registry) + : base(environment) + { + _registry = registry; + } + + /// + /// AC-12: the registry's domain-list answers the four-field contract the runtime's bulk + /// read is written against — one flat items array, no pagination envelope. + /// + [SkippableFact] + public async Task DomainList_ServesTheFlatFourFieldContract() + { + RequireRegistry(); + + var (status, body) = await GetRegistryAsync(DomainListPath); + + Assert.Equal(HttpStatusCode.OK, status); + + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + + // The envelope is deliberately NOT the instance-list one ({ links, items: [ { key, metadata, + // attributes } ] }). A regression to that shape would leave every domainName empty and the + // runtime would warm nothing while still reporting success. + Assert.False(root.TryGetProperty("links", out _), $"domain-list must not carry a pagination envelope: {body}"); + Assert.True(root.TryGetProperty("items", out var items), $"no items array in: {body}"); + Assert.Equal(JsonValueKind.Array, items.ValueKind); + + var listed = items.EnumerateArray().ToList(); + Assert.True(listed.Count > 0, + "the registry reported no domains at all; the runtime treats an empty list as a failed refresh window"); + + foreach (var item in listed) + { + Assert.True(NonEmpty(item, "domainName"), $"item without domainName: {item}"); + Assert.True(NonEmpty(item, "baseUrl"), $"item without baseUrl: {item}"); + + // The fields the runtime maps onto DomainRegistration. appId/healthUrl may be empty for a + // registration that never carried them, but the properties must exist. + Assert.True(item.TryGetProperty("appId", out _), $"item without appId: {item}"); + Assert.True(item.TryGetProperty("healthUrl", out _), $"item without healthUrl: {item}"); + } + } + + /// + /// AC-13: a forced refresh reads that list and publishes it. Refreshed is the assertion + /// with teeth — the refresher reports it only when the read succeeded AND returned a non-empty + /// list AND every entry was written to the cache; a 404, an unparsable body or an empty list all + /// come back as Failed. + /// + [SkippableFact] + public async Task ForcedRefresh_ReadsTheDomainListAndPublishesTheCache() + { + RequireRegistry(); + + var (status, body) = await SendRawAsync(HttpMethod.Post, RefreshPath); + + Assert.Equal(HttpStatusCode.OK, status); + + var outcome = Outcome(body); + RequireCacheEnabled(outcome, body); + + Assert.True(outcome == "Refreshed", + $"the warm-up did not read the registry's domain-list: {body}. " + + "Failed here means the runtime's bulk read did not come back with a usable list — check the " + + "orchestration log for DomainListEndpointMissing (the registry package predates domain-list) " + + "or a non-2xx from the registry."); + } + + /// + /// AC-14: what the runtime warms follows the registry. A domain registered now appears in + /// domain-list — the registry evicts its own 24 h function cache on registration — and the + /// very next forced refresh still succeeds, so the runtime is reading the live list rather than + /// a value frozen at its own startup. + /// + [SkippableFact] + public async Task NewRegistration_IsVisibleToTheNextWarmUp() + { + RequireRegistry(); + + // Skip before writing anything to the registry if the cache is off; otherwise the test would + // leave a synthetic domain behind for a run that could never assert on it. + var (_, probe) = await SendRawAsync(HttpMethod.Post, RefreshPath); + RequireCacheEnabled(Outcome(probe), probe); + + var domain = $"warmup-probe-{Guid.NewGuid():N}"[..24]; + var baseUrl = $"http://{domain}.invalid:5000"; + + var registered = await RegisterDomainAsync(domain, baseUrl); + Assert.True(registered.Status is HttpStatusCode.OK or HttpStatusCode.Accepted or HttpStatusCode.Created, + $"could not register the probe domain: {(int)registered.Status} {registered.Body}"); + + var (_, listBody) = await GetRegistryAsync(DomainListPath); + using var document = JsonDocument.Parse(listBody); + + var entry = document.RootElement.GetProperty("items").EnumerateArray() + .FirstOrDefault(i => i.GetProperty("domainName").GetString() == domain); + + Assert.True(entry.ValueKind == JsonValueKind.Object, + $"'{domain}' was registered but is not in domain-list; the registry's function cache " + + $"(discovery:domains:active) was not evicted by the registration: {listBody}"); + Assert.Equal(baseUrl, entry.GetProperty("baseUrl").GetString()); + + var (status, body) = await SendRawAsync(HttpMethod.Post, RefreshPath); + Assert.Equal(HttpStatusCode.OK, status); + Assert.True(Outcome(body) == "Refreshed", $"the warm-up failed on the grown list: {body}"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private string DomainListPath => $"api/v1/{_registry.RegistryDomain}/functions/domain-list"; + + private void RequireRegistry() => + Skip.If(_registry.RegistryBaseUrl is null, + "VNEXT_DISCOVERY_BASE_URL is not set — a discovery registry carrying " + + "@burgan-tech/vnext-discovery-runtime >= 0.0.7 is required."); + + /// + /// The refresh endpoint answers disabled when no IDiscoveryCacheRefresher is + /// registered, which is the correct state under Provider=dapr or with the cache switched + /// off. That is a configuration, not a defect, so the test steps aside instead of failing. + /// + private static void RequireCacheEnabled(string? outcome, string body) => + Skip.If(outcome == "disabled", + "the discovery cache is not enabled on this runtime — run it with " + + $"ServiceDiscovery__Provider=http and ServiceDiscovery__Cache__Enabled=true ({body})"); + + private static string? Outcome(string body) + { + using var document = JsonDocument.Parse(body); + return document.RootElement.TryGetProperty("outcome", out var outcome) ? outcome.GetString() : null; + } + + private static bool NonEmpty(JsonElement item, string property) => + item.TryGetProperty(property, out var value) && !string.IsNullOrWhiteSpace(value.GetString()); + + private async Task<(HttpStatusCode Status, string Body)> GetRegistryAsync(string path) + { + using var response = await _registry.Client.GetAsync(path); + return (response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + /// + /// Registers a domain the way the runtime's own DomainRegistrationService does: a + /// synchronous start of the registry's domain-registration flow, instance key = domain name. + /// + private async Task<(HttpStatusCode Status, string Body)> RegisterDomainAsync(string domain, string baseUrl) + { + var payload = new + { + key = domain, + tags = new[] { "domain", "registration", "integration-test" }, + attributes = new + { + domainName = domain, + baseUrl, + healthUrl = $"{baseUrl}/health", + appId = $"vnext-{domain}-app" + } + }; + + using var response = await _registry.Client.PostAsJsonAsync( + $"api/v1/{_registry.RegistryDomain}/workflows/domain-registration/instances/start?sync=true", + payload); + + return (response.StatusCode, await response.Content.ReadAsStringAsync()); + } +} diff --git a/tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md b/tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md index a965b0d..bbca4c1 100644 --- a/tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md +++ b/tests/Core.IntegrationTests/Tests/CrossDomainLab/README.md @@ -1,4 +1,4 @@ -# CrossDomainLab — cross-domain SubFlow, trigger task'ları ve fonksiyon descent'i (core → partner) +# CrossDomainLab — cross-domain SubFlow, trigger task'ları, fonksiyon descent'i (core → partner) ve discovery warm-up ## Neyi denetliyor @@ -8,6 +8,11 @@ Service Invocation** üzerinden tüketir (`ServiceDiscovery:Provider=dapr`). Sui tipi (11 Start, 12 DirectTrigger, 13 GetInstanceData, 14 SubProcess, 15 GetInstances, 19 GetInstance) `useDapr:true` ile partner'a ulaşır. +Suite'in ikinci yarısı (`DiscoveryWarmUpTests`) transport'u değil **adres kaynağını** denetler: +runtime'ın discovery endpoint cache'ini registry'nin `domain-list` fonksiyonundan doldurması. Bu kısım +`partner`'a değil, yalnız `core` + registry'ye ihtiyaç duyar ve `ServiceDiscovery:Provider=http` +ister — cache yalnız HTTP provider'da register edilir. + ## Neden var Cross-domain adres çözümlemesi Discovery registry HTTP'sinden Dapr'a taşındı @@ -37,10 +42,14 @@ bekler, status'u değil. Her task ayrı transition'da: kırmızı bir test tek b |---|---|---| | `SubflowDescentTests` | 01–06 | child start (partner'da), `state`/`view`/`schema`/`authorize` descent'i, `data?extensions=` descent'i (gövde parent'ta kalır — runtime kararı), parent üzerinden `child-approve` forward + parent resume | | `TriggerTaskTests` | 07–11 | 14 fire-and-forget worker, 11 sync start (+`remoteInstanceId`/`remoteKey`), 12 `remote-advance`, 19+13 okuma (`remoteState`, `remoteData.testId`), 15 `attributes.testId` filtresiyle liste | +| `DiscoveryWarmUpTests` | 12–14 | registry'nin `domain-list` sözleşmesi (düz `items[]`, dört alan, sayfalama zarfı **yok**), `POST utilities/discovery/refresh` → `Refreshed` (runtime function'ı okuyup cache'i yazdı), yeni bir kayıttan sonra listenin büyümesi ve warm-up'ın hâlâ başarılı olması | `CrossDomainLabFixture` partner bileşenlerini (`partner/`, `vnext.partner.config.json`) bir kez yayınlar — harici-stack modunda SDK'nın `OnAfterEnvironmentReadyAsync` hook'u çağrılmadığı için fixture'da. -`VNEXT_PARTNER_BASE_URL` yoksa tüm testler **skip** (`Xunit.SkippableFact`). +`VNEXT_PARTNER_BASE_URL` yoksa `SubflowDescentTests` + `TriggerTaskTests` **skip** (`Xunit.SkippableFact`). +`DiscoveryWarmUpTests` ayrı bir fixture (`DiscoveryRegistryFixture`) ve ayrı bir değişken kullanır — +`VNEXT_DISCOVERY_BASE_URL` yoksa skip; cache kapalıysa (refresh `disabled` döner) yine skip, çünkü +`Provider=dapr` altında cache hiç register edilmez ve bu bir kusur değil konfigürasyondur. ## Çalıştırma @@ -52,7 +61,18 @@ cd tests/Core.IntegrationTests dotnet test --settings test.runsettings --filter "FullyQualifiedName~CrossDomainLab" ``` -`test.runsettings`: `VNEXT_BASE_URL=http://localhost:4201`, `VNEXT_PARTNER_BASE_URL=http://localhost:4211`. +`test.runsettings`: `VNEXT_BASE_URL=http://localhost:4201`, `VNEXT_PARTNER_BASE_URL=http://localhost:4211`, +`VNEXT_DISCOVERY_BASE_URL=http://localhost:4231`. Farklı port/offset kullanıyorsan **committed dosyayı +düzenleme**, yanına git-ignore'lu `test.runsettings.local` koy. + +`DiscoveryWarmUpTests` için ek koşullar: + +- registry `@burgan-tech/vnext-discovery-runtime` **>= 0.0.7** taşımalı (`domain-list` ilk o sürümde); + lab bunu init container'ından yayınlar (`lab.sh` içinde `VNEXT_DISCOVERY_PACKAGE_VERSION`). +- core `ServiceDiscovery__Enabled=true`, `ServiceDiscovery__Provider=http`, + `ServiceDiscovery__Cache__Enabled=true` ve `ServiceDiscovery__BaseUrl=/api/v1` ile + koşmalı. Lab'ın varsayılanı `Provider=dapr`'dır: `VNEXT_LAB_DISCOVERY_PROVIDER=http` ile kaldır. +- Üç domain'lik lab şart değil; `core` + registry yeten en küçük kurulumdur. Script gövdeleri (`.csx`) değiştiğinde `python3 labs/cross-domain/encode-scripts.py` ile `code` alanlarını yenile (runtime `location`'dan değil `code`'dan derler). @@ -73,3 +93,9 @@ referanslayan yerleri güncelle (ör. `xd-child-ext 1.0.1` → `xd-child.extensi - `Discovery.Resolve/partner` span etiketleri (`vnext.discovery.provider=dapr`, `vnext.dapr.app_id=vnext-app-partner`) manuel doğrulanır (OpenObserve :5080); test assert etmez. - Hata enjeksiyonu (callee kapalı → `ERR_DIRECT_INVOKE` → `remote_network_error`) ikinci faz. +- `DiscoveryWarmUpTests` cache'in **içeriğini** okuyamaz: runtime'da cache'i geri okuyan bir endpoint + yok. `Refreshed` sonucu "okuma başarılı **ve** liste boş değil **ve** her kayıt yazıldı" demektir + (refresher boş listeyi `Failed` sayar); kayıtların kendisi Redis'ten + `vnext||discovery:domain:v1:` ile elle doğrulanır. +- AC-14 registry'ye sentetik bir `warmup-probe-*` domain'i yazar ve **silmez** — kayıt akışının + geri alma yolu yok. Lokal lab DB'sinde zararsızdır; paylaşılan bir registry'ye karşı koşturma. diff --git a/tests/Core.IntegrationTests/Tests/HumanTaskChain/EffectiveProjectionResetTests.cs b/tests/Core.IntegrationTests/Tests/HumanTaskChain/EffectiveProjectionResetTests.cs new file mode 100644 index 0000000..68df43a --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/HumanTaskChain/EffectiveProjectionResetTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.HumanTaskChain; + +/// +/// When a blocking SubFlow finishes, the parent's whole effective projection must come back to the +/// parent's OWN state — state key, state type AND state sub type — and the long-poll fingerprint +/// must move with it. +/// +/// +/// +/// Why it exists: EffectiveStateSubType had no reset writer anywhere. The SubFlow terminal +/// paths reset the state key and the status and left the type/sub-type pair behind, and the resume +/// re-enters the pipeline at ClearBusyOnResumeStep (order 79), past ChangeStateStep +/// (50) — so the resume hop never rewrites it either. A parent whose child had been in a Human +/// state therefore carried subType = 6 permanently and was offered as an open human task to +/// every caller, forever, on an instance that had already moved on. +/// +/// +/// The projection is also long-poll fingerprint material, so the same defect meant a client polling +/// that parent could keep receiving 304 Not Modified while the instance had in fact changed. +/// Both halves are asserted here: the phantom row disappears, and the poll wakes up. +/// +/// +public class EffectiveProjectionResetTests(VNextTestEnvironment environment) + : WorkflowTestBase(environment) +{ + private const string Root = "ht-a"; + private const string ApproverRole = "ht-approver"; + + /// + /// Reads the list bypassing the response cache. Both assertions here are about a change that + /// has just happened, and the cache's TTL is longer than any reasonable wait — polling through + /// it would time out on a stale answer and say nothing about the projection. + /// + private async Task IsListedAsync(string instanceId) + { + var headers = Headers(ApproverRole); + headers["X-VNext-Cache-Override"] = "true"; + + var (status, body) = await SendRawAsync( + HttpMethod.Get, "api/v1/core/functions/human-task", body: null, headers: headers); + + Assert.True(status == HttpStatusCode.OK, $"human-task function failed: {status} {body}"); + + using var document = JsonDocument.Parse(body); + return document.RootElement.EnumerateArray().Any(row => + row.TryGetProperty("id", out var id) && id.GetString() == instanceId); + } + + /// + /// One level down — A holds B, B rests in its human state. Approving through the root forwards + /// to the active subflow; when B completes, A must stop looking like a human task. + /// + [SkippableFact] + public async Task WhenTheSubFlowCompletes_TheParentStopsBeingListedAsAHumanTask() + { + var instanceId = await StartAsync(Root, new + { + hops = 1, + testId = Guid.NewGuid().ToString("N"), + humanTask = new { title = "HT-A step", description = "HT-A step description" } + }, ApproverRole); + + await AssertNotFaultedAsync(Root, instanceId, ApproverRole); + await WaitUntilAsync( + async () => await IsListedAsync(instanceId), + $"{instanceId} never surfaced as a human task while its child waited", + TimeSpan.FromMinutes(2)); + + // Addressed at the root: the runtime forwards the transition to the active subflow, which + // is the only path a client has — it does not know the child. + await RunAcceptedAsync(Root, instanceId, "ht-b-approve", roles: ApproverRole); + + await WaitUntilAsync( + async () => !await IsListedAsync(instanceId), + $"{instanceId} is STILL listed after its child completed — the effective projection did " + + "not reset, which is the phantom human task this scenario exists for", + TimeSpan.FromMinutes(2)); + } + + /// + /// The same moment, seen by a long-poller. The effective state and sub type are fingerprint + /// material, so a child that finished has to move the ETag — otherwise a parked client keeps + /// getting 304 on an instance that has changed underneath it. + /// + [SkippableFact] + public async Task TheSubFlowsCompletionMovesTheParentsLongPollFingerprint() + { + var instanceId = await StartAsync(Root, new + { + hops = 1, + testId = Guid.NewGuid().ToString("N"), + humanTask = new { title = "HT-A step", description = "HT-A step description" } + }, ApproverRole); + + await AssertNotFaultedAsync(Root, instanceId, ApproverRole); + await WaitUntilAsync( + async () => await IsListedAsync(instanceId), + $"{instanceId} never surfaced as a human task while its child waited", + TimeSpan.FromMinutes(2)); + + var (firstStatus, etag, _) = await PollStateAsync(Root, instanceId, roles: ApproverRole); + Assert.Equal(HttpStatusCode.OK, firstStatus); + Assert.False(string.IsNullOrWhiteSpace(etag), "state function returned no ETag to poll against"); + + // Nothing has happened yet, so the same ETag must still be current: this proves the later + // 200 is caused by the completion and not by an ETag that never matches anything. + var (unchangedStatus, _, _) = await PollStateAsync(Root, instanceId, etag, ApproverRole); + Assert.Equal(HttpStatusCode.NotModified, unchangedStatus); + + await RunAcceptedAsync(Root, instanceId, "ht-b-approve", roles: ApproverRole); + + await WaitUntilAsync( + async () => + { + var (status, _, _) = await PollStateAsync(Root, instanceId, etag, ApproverRole); + return status == HttpStatusCode.OK; + }, + "the parent's long-poll never woke after its child completed — the effective projection " + + "is fingerprint material and did not move"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskChainFixture.cs b/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskChainFixture.cs new file mode 100644 index 0000000..6aca53c --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskChainFixture.cs @@ -0,0 +1,43 @@ +using VNext.Testing.Sdk.Infrastructure; + +namespace Core.IntegrationTests.Tests.HumanTaskChain; + +/// +/// Publishes the credit domain's components (credit/, described by +/// vnext.credit.config.json) to the credit orchestrator once per test run. +/// +/// +/// Same shape and same reason as CrossDomainLabFixture: in external-stack mode the SDK never +/// calls OnAfterEnvironmentReadyAsync, so anything beyond the core publish has to be +/// done by a fixture. partner is published by CrossDomainLabFixture, which these tests +/// also take — credit is the only domain this one owns, so the two never publish the same components. +/// +public sealed class HumanTaskChainFixture : IAsyncLifetime +{ + private static readonly SemaphoreSlim Gate = new(1, 1); + private static bool _published; + + /// Credit orchestrator base url, or null when the lab's third business domain is absent. + public string? CreditBaseUrl { get; } = + Environment.GetEnvironmentVariable("VNEXT_CREDIT_BASE_URL")?.Trim().TrimEnd('/') is { Length: > 0 } url ? url : null; + + public async Task InitializeAsync() + { + if (CreditBaseUrl is null) return; + + await Gate.WaitAsync(); + try + { + if (_published) return; + Console.WriteLine($"[HumanTaskChain] publishing credit components to {CreditBaseUrl}"); + await LocalDomainPublisher.PublishAsync(CreditBaseUrl, "credit", "vnext.credit.config.json"); + _published = true; + } + finally + { + Gate.Release(); + } + } + + public Task DisposeAsync() => Task.CompletedTask; +} diff --git a/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskFunctionTests.cs b/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskFunctionTests.cs new file mode 100644 index 0000000..b9c0781 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/HumanTaskChain/HumanTaskFunctionTests.cs @@ -0,0 +1,893 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; +using Core.IntegrationTests.Tests.CrossDomainLab; + +namespace Core.IntegrationTests.Tests.HumanTaskChain; + +/// +/// The human-task domain function answers "which human tasks is this caller expected to act +/// on". These pin the two things that make that answer correct when the work is not where the +/// client is looking: the row is addressed by the ROOT, and its authorization and text come from +/// the LEAF — however many SubFlow levels and domain boundaries away that leaf is. +/// +/// +/// +/// Why it exists: the function used to descend exactly ONE level, resolve the child's definition +/// against the CALLER's domain instead of the correlation's, and read the title from the root. So a +/// task two levels down was authorized against the wrong state, and a cross-domain child resolved +/// to nothing and silently removed its whole instance from the list. +/// +/// +/// The chain's depth is data: the start payload's hops decides which level ends up holding +/// the task, so one definition family covers all three shapes. See +/// api-tests/human-task-chain/build-human-task-chain.py. +/// +/// +public class HumanTaskFunctionTests(VNextTestEnvironment environment, CrossDomainLabFixture lab, HumanTaskChainFixture credit) + : WorkflowTestBase(environment), IClassFixture, IClassFixture +{ + private const string Root = "ht-a"; + private const string ApproverRole = "ht-approver"; + + /// One row of the human-task response. + private sealed record HumanTaskRow(string InstanceId, string Id, string Workflow, string Title, string Description); + + /// + /// Reads the list. sends the cache-override header, which is what a + /// client uses at a moment it cannot tolerate the response cache's TTL — and what a test + /// polling for a change must use, since the cache would otherwise serve the pre-change answer + /// for a full TTL and the wait would time out against a stale list rather than a wrong one. + /// + private async Task> ListHumanTasksAsync( + string? roles = ApproverRole, bool fresh = false) + { + var headers = Headers(roles); + if (fresh) headers["X-VNext-Cache-Override"] = "true"; + + var (status, body) = await SendRawAsync( + HttpMethod.Get, "api/v1/core/functions/human-task", body: null, headers: headers); + + Assert.True(status == HttpStatusCode.OK, $"human-task function failed: {status} {body}"); + + return ParseHumanTasks(body); + } + + /// + /// The same read against ANOTHER domain's orchestrator. morph-idm fans out over every + /// registered domain and merges the answers, so a row that only exists in partner's own list is + /// a row the aggregator will show — a SubProcess is listed by the domain that OWNS it, never by + /// the domain its parent lives in. + /// + private static async Task> ListHumanTasksAsync( + string baseUrl, string domain, string? roles = ApproverRole, bool fresh = false) + { + using var client = new HttpClient { BaseAddress = new Uri(baseUrl + "/") }; + using var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/{domain}/functions/human-task"); + + var headers = Headers(roles); + if (fresh) headers["X-VNext-Cache-Override"] = "true"; + foreach (var (key, value) in headers) request.Headers.TryAddWithoutValidation(key, value); + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"human-task function on {domain} failed: {response.StatusCode} {body}"); + + return ParseHumanTasks(body); + } + + private static IReadOnlyList ParseHumanTasks(string body) + { + using var document = JsonDocument.Parse(body); + return document.RootElement.EnumerateArray() + .Select(row => new HumanTaskRow( + row.GetProperty("instanceId").GetString() ?? string.Empty, + row.TryGetProperty("id", out var id) ? id.GetString() ?? string.Empty : string.Empty, + row.GetProperty("workflow").GetString() ?? string.Empty, + row.GetProperty("title").GetString() ?? string.Empty, + row.GetProperty("description").GetString() ?? string.Empty)) + .ToList(); + } + + /// + /// Starts the root through the raw client WITHOUT sync=true. The SDK client hard-codes + /// the synchronous mode, and the synchronous mode is the one a state-level SubProcess cannot + /// survive today (see the remarks on the SubProcess test). + /// + private async Task StartAsyncAndGetIdAsync(object attributes) + { + var (status, body) = await SendRawAsync( + HttpMethod.Post, + $"api/v1/core/workflows/{Root}/instances/start", + new { key = $"ht-spawn-{Guid.NewGuid():N}", attributes }, + Headers(ApproverRole)); + + Assert.True(status == HttpStatusCode.Accepted || status == HttpStatusCode.OK, + $"async start failed: {status} {body}"); + + using var document = JsonDocument.Parse(body); + return document.RootElement.GetProperty("id").GetString()!; + } + + /// Calls a built-in instance function on whichever domain owns the flow. + private static async Task<(HttpStatusCode Status, string Body)> FunctionAsync( + string baseUrl, string domain, string flow, string instanceId, string function, string roles, + string? query = null) + { + using var client = new HttpClient { BaseAddress = new Uri(baseUrl + "/") }; + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"api/v1/{domain}/workflows/{flow}/instances/{instanceId}/functions/{function}{query}"); + foreach (var (key, value) in Headers(roles)) request.Headers.TryAddWithoutValidation(key, value); + + using var response = await client.SendAsync(request); + return (response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + /// `authorize`'s verdict. + /// + /// The verdict is in the BODY on both statuses: a refusal answers 403 with + /// {"allowed":false}, not an empty error. Reading only the 200 would silently turn every + /// refusal into "no answer" and make a denial assertion pass for the wrong reason. + /// + private static async Task AuthorizeAsync( + string baseUrl, string domain, string flow, string instanceId, string roles, string query) + { + var (status, body) = await FunctionAsync(baseUrl, domain, flow, instanceId, "authorize", roles, query); + Assert.True(status is HttpStatusCode.OK or HttpStatusCode.Forbidden, + $"authorize answered {status}: {body}"); + + using var document = JsonDocument.Parse(body); + return document.RootElement.GetProperty("allowed").GetBoolean(); + } + + /// Whether the state function offers , or null on a 403. + private static async Task OffersAsync( + string baseUrl, string domain, string flow, string instanceId, string roles, string transitionKey) + { + var (status, body) = await FunctionAsync(baseUrl, domain, flow, instanceId, "state", roles); + if (status == HttpStatusCode.Forbidden) return null; + Assert.Equal(HttpStatusCode.OK, status); + + using var document = JsonDocument.Parse(body); + return document.RootElement.GetProperty("transitions").EnumerateArray() + .Any(t => t.GetProperty("name").GetString() == transitionKey); + } + + /// The instance of within a chain rooted at . + /// + /// Walked from the root rather than assumed, because the chain's depth is data: the instance ids + /// are only discoverable through each level's active correlation. The walk crosses into partner, + /// so each hop is made against the domain that owns the level. + /// + private async Task ResolveLevelAsync(string rootId, string targetFlow) + { + var flow = Root; + var id = rootId; + + for (var depth = 0; depth < 10 && flow != targetFlow; depth++) + { + var (baseUrl, domain) = EndpointOf(flow); + using var document = await StateFunctionAsync(baseUrl, domain, flow, id, ApproverRole); + + var correlations = document.RootElement.GetProperty("activeCorrelations"); + Assert.True(correlations.GetArrayLength() > 0, $"{flow}/{id} has no active subflow to walk into"); + + flow = correlations[0].GetProperty("subFlowName").GetString()!; + id = correlations[0].GetProperty("subFlowInstanceId").GetString()!; + } + + Assert.Equal(targetFlow, flow); + return id; + } + + /// The transition keys a state function offers a caller — the actionability surface. + private static async Task> AvailableTransitionsAsync( + string baseUrl, string domain, string flow, string instanceId, string roles) + { + using var document = await StateFunctionAsync(baseUrl, domain, flow, instanceId, roles); + return [.. document.RootElement.GetProperty("transitions").EnumerateArray() + .Select(t => t.GetProperty("name").GetString() ?? string.Empty)]; + } + + /// Reads a state function from whichever domain owns the flow. + private static async Task StateFunctionAsync( + string baseUrl, string domain, string flow, string instanceId, string roles) + { + using var client = new HttpClient { BaseAddress = new Uri(baseUrl + "/") }; + using var request = new HttpRequestMessage( + HttpMethod.Get, $"api/v1/{domain}/workflows/{flow}/instances/{instanceId}/functions/state"); + foreach (var (key, value) in Headers(roles)) request.Headers.TryAddWithoutValidation(key, value); + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"state function failed for {domain}/{flow}/{instanceId} as [{roles}]: {response.StatusCode} {body}"); + + return JsonDocument.Parse(body); + } + + /// Base url and domain name for a flow of the chain. + private (string BaseUrl, string Domain) EndpointOf(string flow) => flow switch + { + "ht-d" => (lab.PartnerBaseUrl!, "partner"), + "ht-e" or "ht-f" => (credit.CreditBaseUrl!, "credit"), + _ => (CoreBaseUrl, "core") + }; + + private static string CoreBaseUrl => + System.Environment.GetEnvironmentVariable("VNEXT_BASE_URL")?.TrimEnd('/') ?? "http://localhost:4201"; + + /// + /// Starts a chain and waits until it has come to rest on a human state somewhere below, which + /// is exactly when the root becomes listable. + /// + private async Task StartChainAsync(int hops, string? visibleTo = null) + { + var instanceId = await StartAsync(Root, new + { + hops, + testId = Guid.NewGuid().ToString("N"), + humanTask = new { title = "HT-A step", description = "HT-A step description" } + }, ApproverRole); + + await AssertNotFaultedAsync(Root, instanceId, ApproverRole); + + // Waited for as the role that can actually SEE it. A level whose queryRoles the parent + // overrode is invisible to the broad role by design, so polling with that role here would + // burn the whole timeout on a list that is correctly not showing it. + await WaitUntilAsync( + async () => (await ListHumanTasksAsync(roles: visibleTo ?? ApproverRole, fresh: true)) + .Any(row => row.Id == instanceId), + $"chain {instanceId} (hops={hops}) never surfaced as a human task", + TimeSpan.FromMinutes(2)); + + return instanceId; + } + + /// + /// Senaryo 1 — A → B → C, all in core. The leaf is C, three levels below the row the client sees. + /// + [SkippableFact] + public async Task Scenario1_ThreeLevelsInOneDomain_ListsTheRootWithTheLeafsText() + { + var instanceId = await StartChainAsync(hops: 2); + + var row = (await ListHumanTasksAsync(fresh: true)).Single(r => r.Id == instanceId); + + // The row is addressed by the root, which is all the client knows. + Assert.Equal(Root, row.Workflow); + // The text must come from the leaf, not from the root's own data. + Assert.Equal("HT-C step", row.Title); + Assert.Equal("HT-C step description", row.Description); + } + + /// + /// Senaryo 2 — A → B → C in core, then D in partner. One boundary; before the fix this instance + /// left the list entirely, because the child's definition was resolved against core. + /// + [SkippableFact] + public async Task Scenario2_CrossingIntoPartner_StillListsTheRootWithTheLeafsText() + { + Skip.If(lab.PartnerBaseUrl is null, "VNEXT_CREDIT/PARTNER lab not configured — run labs/cross-domain/lab.sh up"); + + var instanceId = await StartChainAsync(hops: 3); + + var row = (await ListHumanTasksAsync(fresh: true)).Single(r => r.Id == instanceId); + + Assert.Equal(Root, row.Workflow); + // The leaf lives in partner; its text is what the banker must read. + Assert.Equal("HT-D step", row.Title); + } + + /// + /// Senaryo 3 — A → B → C (core) → D (partner) → E → F (credit). Two boundaries, and the second + /// one is crossed by the PARTNER runtime: core never talks to credit. + /// + [SkippableFact] + public async Task Scenario3_TwoBoundaries_ResolvesAllTheWayToTheCreditLeaf() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + var instanceId = await StartChainAsync(hops: 5); + + var row = (await ListHumanTasksAsync(fresh: true)).Single(r => r.Id == instanceId); + + // Six levels and two domains down, the row is still the root's. + Assert.Equal(Root, row.Workflow); + Assert.Equal("HT-F step", row.Title); + } + + /// + /// A root resting in its OWN human state is its own leaf — the simplest shape, and the one an + /// implementation that always descends would get wrong. + /// + [SkippableFact] + public async Task ARootRestingInItsOwnHumanStateIsListedWithItsOwnText() + { + var instanceId = await StartChainAsync(hops: 0); + + var row = (await ListHumanTasksAsync(fresh: true)).Single(r => r.Id == instanceId); + + Assert.Equal(Root, row.Workflow); + Assert.Equal("HT-A step", row.Title); + } + + /// + /// One workflow declares several human states, and two instances of it can be waiting in + /// different ones at the same time — one in the root's own, one several levels down. Both belong + /// in the list, each carrying the text of the state ITS work is actually parked in. + /// + /// + /// This is the shape that makes "where is the task" a per-instance question rather than a + /// per-definition one: the definition says nothing about which of its human states matters, and + /// only the instance's effective position does. + /// + [SkippableFact] + public async Task TwoInstancesOfOneFlowAreListedByWhereEachIsEffectivelyWaiting() + { + var atOwnHumanState = await StartChainAsync(hops: 0); + var waitingTwoLevelsDown = await StartChainAsync(hops: 2); + + var rows = await ListHumanTasksAsync(fresh: true); + + var shallow = rows.Single(r => r.Id == atOwnHumanState); + var deep = rows.Single(r => r.Id == waitingTwoLevelsDown); + + // Same workflow, same root identity shape — different effective positions, different text. + Assert.Equal(Root, shallow.Workflow); + Assert.Equal(Root, deep.Workflow); + Assert.Equal("HT-A step", shallow.Title); + Assert.Equal("HT-C step", deep.Title); + Assert.NotEqual(shallow.Id, deep.Id); + } + + /// + /// A SubProcess is an independent unit of work, so it gets its OWN row — addressed by its own + /// id, not by the business key it inherited from the case that spawned it — and it descends + /// through its own SubFlows exactly like a root does. + /// + /// + /// The root spawns ht-d as a SubProcess and carries straight on to its own human state: + /// nothing waits for the child and nothing projects its state upward. Two independent rows must + /// therefore appear. Before Type IN ('R','P') the SubProcess branch was invisible + /// entirely, and while the row carried Key it collided with the root's and led a client + /// following it to the wrong instance. + /// + [SkippableFact] + public async Task ASpawnedSubProcessIsListedOnItsOwnAndDescendsLikeARoot() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + // hops = 0 keeps the ROOT in its own human state; processHops = 2 sends the spawned + // SubProcess down two more SubFlow levels, so its leaf is ht-f in credit. + // + // Started ASYNC on purpose. A state-level SubProcess followed by an automatic transition + // cannot be started synchronously today: the post-commit ContinueParent continuation + // re-enters the pipeline with IsPreReserved = false while the instance is still Busy from + // the stage that continuation belongs to, so admission rejects it with + // "conflict.Instance:100031". The async path only escapes it because a job re-entry sets + // IsPreReserved independently. That is a runtime defect in the transition pipeline, not in + // the human-task function — see TEST-SCENARIOS.md § Bilinen Kapsam Açıkları. + var rootId = await StartAsyncAndGetIdAsync(new + { + mode = "process", + hops = 0, + processHops = 2, + testId = Guid.NewGuid().ToString("N"), + humanTask = new { title = "HT-A step", description = "HT-A step description" } + }); + + await AssertNotFaultedAsync(Root, rootId, ApproverRole); + + // The root is core's row, under its own business key, resting in its OWN human state. + await WaitUntilAsync( + async () => (await ListHumanTasksAsync(fresh: true)).Any(row => row.Id == rootId), + $"root {rootId} never surfaced in core's human-task list", + TimeSpan.FromMinutes(2)); + + // The SubProcess is PARTNER's row — it is an independent flow, so the domain that owns it + // lists it, and it descends through its own SubFlows into credit exactly like a root would. + HumanTaskRow? spawned = null; + await WaitUntilAsync( + async () => + { + var partnerRows = await ListHumanTasksAsync(lab.PartnerBaseUrl!, "partner", fresh: true); + spawned = partnerRows.FirstOrDefault( + row => row.Workflow == "ht-d" && row.Title == "HT-F step"); + return spawned is not null; + }, + "the spawned SubProcess never surfaced in partner's list with its leaf's text", + TimeSpan.FromMinutes(2)); + + var root = (await ListHumanTasksAsync(fresh: true)).Single(r => r.Id == rootId); + + Assert.Equal(Root, root.Workflow); + Assert.Equal("HT-A step", root.Title); + + // The identity rule this test exists for: a SubProcess inherits its parent's business Key, + // so addressing it by Key would send a client to the ROOT. It is addressed by its own id. + Assert.Equal(spawned!.Id, spawned.InstanceId); + Assert.NotEqual(root.InstanceId, spawned.InstanceId); + Assert.NotEqual(root.Id, spawned.Id); + + // ...and it reached that text by descending its own two SubFlow levels into credit. + Assert.Equal("HT-F step", spawned.Title); + } + + /// + /// The authorization decision is the LEAF's. A caller holding none of the leaf's grants must not + /// see the task — proving the filter did not fall back to the root's state, which would have + /// been evaluated against a different transition set. + /// + [SkippableFact] + public async Task ACallerWithoutTheLeafsRoleDoesNotSeeTheTask() + { + var instanceId = await StartChainAsync(hops: 2); + + var rows = await ListHumanTasksAsync(roles: "some.other.role", fresh: true); + + Assert.DoesNotContain(rows, row => row.Id == instanceId); + } + + // ──────────────────────────────────────────────────────────────────────────────── + // Authorization — the gate is the LEAF STATE's queryRoles + // + // The list answers "which human tasks am I responsible for?" — a VISIBILITY question, so it is + // decided by queryRoles, not by whether some transition would admit the caller. Which button is + // offered is settled later, when the client opens the instance and the state function runs. + // + // A wrong answer here is not a slow list, it is a banker seeing another banker's case — and the + // failure is invisible in the response by construction, because an unauthorized row is simply + // absent. Every test below is therefore a PAIR: something the caller must see and something the + // same caller must not, from the same data in the same call. A test that only asserts absence + // cannot tell "correctly filtered" from "the descent dropped it", and that is exactly how this + // scenario's first negative test turned out to be vacuous. + // ──────────────────────────────────────────────────────────────────────────────── + + /// + /// The queryRoles are read from the state the instance is EFFECTIVELY in, not from the + /// flow the row is addressed by. Both chains below are rooted in ht-a and differ only in + /// where they came to rest, and a caller holding just one level's role separates them. + /// + /// + /// The positive half is what makes this a real test. Asserting only that the deep chain is + /// hidden would pass just as well if the descent had failed, the leaf had been misresolved, or + /// the row had never been listed at all — every one of which looks identical to "filtered". + /// The leaf here is ht-f in credit, two domain boundaries away, so it also pins + /// that the decision was taken in the domain that owns the leaf's definition. + /// + [SkippableFact] + public async Task OnlyTheLeafsOwnRoleGrantsTheTaskAndItGrantsNoOther() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + var restsInCredit = await StartChainAsync(hops: 5); // leaf = ht-f (credit) + var restsInCore = await StartChainAsync(hops: 2); // leaf = ht-c (core) + + var rows = await ListHumanTasksAsync(roles: "ht-f-approver", fresh: true); + + Assert.Contains(rows, row => row.Id == restsInCredit); + Assert.DoesNotContain(rows, row => row.Id == restsInCore); + + // ...and the discrimination is the caller's, not the data's: ht-approver is granted at every + // level, so the same two instances are both visible to it. + var broad = await ListHumanTasksAsync(roles: ApproverRole, fresh: true); + Assert.Contains(broad, row => row.Id == restsInCredit); + Assert.Contains(broad, row => row.Id == restsInCore); + } + + /// + /// A DENY grant refuses, whatever else the caller carries. + /// + /// + /// + /// The composition is AllowGroup AND DenyGroup: allows OR among themselves, denies AND + /// among themselves, and the deny group is evaluated first. One breached deny refuses outright — + /// an allowed role no longer buys a denied one back. + /// + /// + /// This test previously pinned the opposite, because the rule used to be applied per caller role + /// inside a loop that returned on the first role that was allowed: a deny for role B was never + /// reached once role A matched an allow, and [approver, blocked] passed. That made a deny + /// grant unusable as a block, which is what the committee changed. The assertions below are the + /// diff. + /// + /// + [SkippableFact] + public async Task ADenyGrantRefusesWhateverElseTheCallerCarries() + { + var instanceId = await StartChainAsync(hops: 2); + + // Allowed: ht-approver is granted by ht-c-human's queryRoles. + var allowed = await ListHumanTasksAsync(roles: ApproverRole, fresh: true); + Assert.Contains(allowed, row => row.Id == instanceId); + + // Refused: the denied role alone. + var deniedAlone = await ListHumanTasksAsync(roles: "ht-blocked", fresh: true); + Assert.DoesNotContain(deniedAlone, row => row.Id == instanceId); + + // Refused: the denied role BESIDE an allowed one. This is the cell that moved. + var both = await ListHumanTasksAsync(roles: $"{ApproverRole},ht-blocked", fresh: true); + Assert.DoesNotContain(both, row => row.Id == instanceId); + + // ...and order does not matter, which is what "the deny group is evaluated first" buys. + var reversed = await ListHumanTasksAsync(roles: $"ht-blocked,{ApproverRole}", fresh: true); + Assert.DoesNotContain(reversed, row => row.Id == instanceId); + } + + /// + /// The response cache must never serve one caller's authorized list to another. + /// + /// + /// Deliberately WITHOUT the cache-override header — every other test in this class sends it, so + /// none of them exercises the cached path at all, and this is the one place where a cache-key + /// mistake becomes a data-leak rather than a stale answer. The first call populates the entry + /// for the broad role; the second, inside the TTL, must build its own rather than be served + /// that one. A key that did not cover caller roles would hand the second caller a row it has no + /// grant for. + /// + [SkippableFact] + public async Task TheResponseCacheDoesNotServeOneCallersListToAnother() + { + var instanceId = await StartChainAsync(hops: 2); + + var warmed = await ListHumanTasksAsync(roles: ApproverRole); + Assert.Contains(warmed, row => row.Id == instanceId); + + // Same endpoint, same instant, different caller. Within the TTL, so a role-blind key would + // hit the entry the line above just wrote. + var other = await ListHumanTasksAsync(roles: "some.other.role"); + Assert.DoesNotContain(other, row => row.Id == instanceId); + + // And the first caller still sees it — proving the second call did not merely evict or + // poison the entry. + var again = await ListHumanTasksAsync(roles: ApproverRole); + Assert.Contains(again, row => row.Id == instanceId); + } + + /// + /// A parent's subFlow.overrides.states narrows the CHILD's visibility, and that narrowing + /// is honoured at the leaf. + /// + /// + /// + /// SubflowStarter stamps the map onto the child at start as + /// subflow.state_role_overrides. Until this change nothing in the runtime read it — a + /// dead write — because the only reader took the parent's definition and needed an active + /// SubFlow correlation, which a leaf by definition does not have. So a parent that narrowed a + /// child's visibility had that narrowing silently discarded exactly where it mattered. + /// + /// + /// ht-a's subflow state overrides ht-b-human's queryRoles to + /// xd-override-only. A chain resting on ht-b (hops = 1) is therefore visible + /// to that role and NOT to ht-approver, which ht-b's own definition grants — the + /// override replaces, it does not merge. A chain resting one level deeper is untouched by it, + /// which is what proves the map was applied per state rather than per instance. + /// + /// + [SkippableFact] + public async Task AParentsStampedStateOverrideNarrowsTheChildsVisibility() + { + var overridden = await StartChainAsync(hops: 4, visibleTo: "xd-override-only"); // leaf = ht-e, overridden by ht-d + var untouched = await StartChainAsync(hops: 2); // leaf = ht-c, not overridden + + var byOverride = await ListHumanTasksAsync(roles: "xd-override-only", fresh: true); + Assert.Contains(byOverride, row => row.Id == overridden); + Assert.DoesNotContain(byOverride, row => row.Id == untouched); + + // ht-b's OWN queryRoles grant ht-approver; the parent's override replaced them. + var byOwnRole = await ListHumanTasksAsync(roles: ApproverRole, fresh: true); + Assert.DoesNotContain(byOwnRole, row => row.Id == overridden); + // ...and the level the parent did not override still answers to its own grants. + Assert.Contains(byOwnRole, row => row.Id == untouched); + } + + /// + /// A DENY carried by the stamped override refuses, and it refuses a caller whose other role the + /// override allows. + /// + [SkippableFact] + public async Task ADenyInTheStampedStateOverrideRefuses() + { + var overridden = await StartChainAsync(hops: 4, visibleTo: "xd-override-only"); + + Assert.Contains( + await ListHumanTasksAsync(roles: "xd-override-only", fresh: true), + row => row.Id == overridden); + + Assert.DoesNotContain( + await ListHumanTasksAsync(roles: "xd-override-only,ht-blocked", fresh: true), + row => row.Id == overridden); + } + + /// + /// The parent's transitions override still governs what the client is OFFERED, which is + /// the half the list no longer decides. + /// + /// + /// + /// Asserted through the OVERRIDING PARENT's state function, because that is the path the override + /// exists for: it is a parent-side narrowing, applied while a client reaches the child through + /// the parent that declared it. Going straight at the child answers 403 from the child's own + /// queryRoles, which is correct and is a different question. + /// + /// + /// A pair, as everywhere else here. ht-d overrode ht-e-approve's roles to + /// xd-override-only, so the roles ht-e's OWN definition grants are no longer + /// offered it — while the identical shape one level up, which nobody overrode, still offers + /// ht-d-approve to exactly those roles. Without the control half, "not offered" would be + /// satisfied just as well by a broken descent. + /// + /// + /// The two overrides are deliberately asserted on different surfaces — states on the list + /// above, transitions here — because that is the split this change is about: visibility is + /// the list's question, actionability is the screen's. + /// + /// + [SkippableFact] + public async Task AParentsTransitionOverrideStillGovernsWhatTheLeafOffers() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + var overridden = await StartChainAsync(hops: 4, visibleTo: "xd-override-only"); + var htd = await ResolveLevelAsync(overridden, "ht-d"); + + // The role ht-d's override names is offered the child's approve, through ht-d. + var offeredToOverride = await AvailableTransitionsAsync( + lab.PartnerBaseUrl!, "partner", "ht-d", htd, "xd-override-only"); + Assert.Contains("ht-e-approve", offeredToOverride); + + // The roles ht-e's OWN definition grants are not — the override replaced them, on both the + // state's visibility and the transition's grants. + foreach (var role in new[] { ApproverRole, "ht-e-approver" }) + { + var offered = await AvailableTransitionsAsync( + lab.PartnerBaseUrl!, "partner", "ht-d", htd, role); + Assert.DoesNotContain("ht-e-approve", offered); + } + + // A DENY inside the override wins across roles, on this surface too. + var denied = await AvailableTransitionsAsync( + lab.PartnerBaseUrl!, "partner", "ht-d", htd, "xd-override-only,ht-blocked"); + Assert.DoesNotContain("ht-e-approve", denied); + + // Control: the same shape one level up, which nobody overrode, still offers its approve to + // exactly those roles. This is what separates "the override applied" from "nothing resolved". + var untouched = await StartChainAsync(hops: 3); + var htc = await ResolveLevelAsync(untouched, "ht-c"); + + foreach (var role in new[] { ApproverRole, "ht-d-approver" }) + { + var offered = await AvailableTransitionsAsync(CoreBaseUrl, "core", "ht-c", htc, role); + Assert.Contains("ht-d-approve", offered); + } + } + + // ── Built-in functions: descent, and one answer per question ───────────────── + // + // A client never addresses the leaf: it holds the root. So every built-in function has to + // descend an active subflow, and every surface has to answer the SAME question the same way at + // both ends of that descent — otherwise the list offers work the screen refuses, or `authorize` + // blesses a transition the state function will not show. + + /// + /// authorize and the state function agree, on the leaf AND through the parent, for both + /// questions they can be asked. + /// + /// + /// + /// A 4×4: {leaf, overriding parent} × {authorize, state} × the role sets the override separates. + /// authorize?transitionKey= asks actionability and the state function's + /// transitions answers the same thing; authorize?queryRoles=true asks visibility + /// and the state function's 403 answers that one. The parameter selects the question — the two + /// surfaces must not disagree once it is the same question. + /// + /// + /// This diverged and was measured diverging: at a directly-addressed leaf, authorize + /// resolved the parent's overrides from the PARENT's definition — which a leaf does not have — + /// and answered from the child's own grants, giving the OPPOSITE verdict to the state function + /// for both roles. The resolution now lives in TransitionAuthorizationManager, so no + /// surface can resolve it its own way again. + /// + /// + [SkippableFact] + public async Task AuthorizeAndTheStateFunctionAgreeOnTheLeafAndThroughTheParent() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + var rootId = await StartChainAsync(hops: 4, visibleTo: "xd-override-only"); + var htd = await ResolveLevelAsync(rootId, "ht-d"); + var hte = await ResolveLevelAsync(rootId, "ht-e"); + + var (partnerUrl, creditUrl) = (lab.PartnerBaseUrl!, credit.CreditBaseUrl!); + + foreach (var roles in new[] { "xd-override-only", "ht-e-approver", ApproverRole, "xd-override-only,ht-blocked" }) + { + // Actionability, both ends of the descent. + var authLeaf = await AuthorizeAsync(creditUrl, "credit", "ht-e", hte, roles, "?transitionKey=ht-e-approve"); + var authParent = await AuthorizeAsync(partnerUrl, "partner", "ht-d", htd, roles, "?transitionKey=ht-e-approve"); + Assert.Equal(authLeaf, authParent); + + // Visibility, both ends, and against the state function which answers the same question. + var seesLeaf = await AuthorizeAsync(creditUrl, "credit", "ht-e", hte, roles, "?queryRoles=true"); + var seesParent = await AuthorizeAsync(partnerUrl, "partner", "ht-d", htd, roles, "?queryRoles=true"); + Assert.Equal(seesLeaf, seesParent); + + // The state function refuses with 403 when visibility is denied (null here), and + // otherwise offers exactly what authorize blessed. + var offeredLeaf = await OffersAsync(creditUrl, "credit", "ht-e", hte, roles, "ht-e-approve"); + Assert.Equal(seesLeaf, offeredLeaf is not null); + if (offeredLeaf is not null) + Assert.Equal(authLeaf, offeredLeaf); + } + } + + /// + /// Only the role the parent's override names gets through — on every one of those surfaces. + /// + /// + /// The paired half of the test above: it asserts the surfaces AGREE, this asserts they agree on + /// the right answer. Without it they could agree by all being broken the same way. + /// + [SkippableFact] + public async Task TheOverriddenRoleIsTheOnlyOneAdmittedOnEverySurface() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + var rootId = await StartChainAsync(hops: 4, visibleTo: "xd-override-only"); + var htd = await ResolveLevelAsync(rootId, "ht-d"); + var (partnerUrl, creditUrl) = (lab.PartnerBaseUrl!, credit.CreditBaseUrl!); + var hte = await ResolveLevelAsync(rootId, "ht-e"); + + // The override names xd-override-only and denies ht-blocked. Replace, not merge: ht-e's own + // grants (ht-approver, ht-e-approver) no longer admit. + Assert.True(await AuthorizeAsync(partnerUrl, "partner", "ht-d", htd, "xd-override-only", "?queryRoles=true")); + Assert.False(await AuthorizeAsync(partnerUrl, "partner", "ht-d", htd, "ht-e-approver", "?queryRoles=true")); + Assert.False(await AuthorizeAsync(partnerUrl, "partner", "ht-d", htd, ApproverRole, "?queryRoles=true")); + + // DENY inside the override wins across roles, on this surface too. + Assert.False(await AuthorizeAsync( + partnerUrl, "partner", "ht-d", htd, "xd-override-only,ht-blocked", "?queryRoles=true")); + + // And the leaf answers identically when addressed on its own url. + Assert.True(await AuthorizeAsync(creditUrl, "credit", "ht-e", hte, "xd-override-only", "?queryRoles=true")); + Assert.False(await AuthorizeAsync(creditUrl, "credit", "ht-e", hte, "ht-e-approver", "?queryRoles=true")); + } + + /// + /// The role order a caller happens to send must not change any answer. + /// + /// + /// Four surfaces used to be handed ICallerRoleResolver.SingleRoleOf(roles) — literally + /// roles[0] — so a grant the caller held was never evaluated unless it happened to be + /// listed first. Measured on this very chain: other,ht-c-approver was offered nothing + /// while ht-c-approver,other was offered the transition. It also put the deny group, an + /// AND across every role, permanently out of reach on those surfaces. + /// + [SkippableFact] + public async Task RoleOrderDoesNotChangeAnyAnswer() + { + var rootId = await StartChainAsync(hops: 2); + var htc = await ResolveLevelAsync(rootId, "ht-c"); + var coreUrl = CoreBaseUrl; + + foreach (var pair in new[] + { + ("other,ht-c-approver", "ht-c-approver,other"), + ($"{ApproverRole},ht-blocked", $"ht-blocked,{ApproverRole}") + }) + { + var (first, reversed) = pair; + + Assert.Equal( + await OffersAsync(coreUrl, "core", "ht-c", htc, first, "ht-c-approve"), + await OffersAsync(coreUrl, "core", "ht-c", htc, reversed, "ht-c-approve")); + + Assert.Equal( + await AuthorizeAsync(coreUrl, "core", "ht-c", htc, first, "?transitionKey=ht-c-approve"), + await AuthorizeAsync(coreUrl, "core", "ht-c", htc, reversed, "?transitionKey=ht-c-approve")); + + Assert.Equal( + await AuthorizeAsync(coreUrl, "core", "ht-c", htc, first, "?queryRoles=true"), + await AuthorizeAsync(coreUrl, "core", "ht-c", htc, reversed, "?queryRoles=true")); + } + + // ...and the answer is the correct one, not merely a stable one: a grant the caller holds + // counts wherever it sits in the list. + Assert.True(await OffersAsync(coreUrl, "core", "ht-c", htc, "other,ht-c-approver", "ht-c-approve")); + } + + /// + /// Every built-in instance function descends an active subflow — except data, which is + /// pinned here as it behaves today. + /// + /// + /// The client holds the ROOT and never the leaf, so a function that answers from the root while + /// the state body describes the leaf hands back something about a different instance. state + /// descends, and so do view, schema, master and extensions. + /// + /// data does NOT, and the state body's own data.href addresses the root — so a + /// client following the link it was given reads the root's attributes while looking at the leaf's + /// state. Pinned rather than asserted-as-correct: whether a client wants the case's data or the + /// leaf's working copy is a product decision, and this test exists so the current answer cannot + /// change unnoticed. See TEST-SCENARIOS.md § Bilinen Kapsam Açıkları. + /// + /// + [SkippableFact] + public async Task TheStateFunctionDescendsButTheDataFunctionDoesNot() + { + var rootId = await StartChainAsync(hops: 2); + var coreUrl = CoreBaseUrl; + + // state descends: the root reports the leaf's state. + var (stateStatus, stateBody) = await FunctionAsync(coreUrl, "core", Root, rootId, "state", ApproverRole); + Assert.Equal(HttpStatusCode.OK, stateStatus); + using (var document = JsonDocument.Parse(stateBody)) + { + Assert.Equal("ht-c-human", document.RootElement.GetProperty("state").GetString()); + + // ...and the data link it hands the client addresses the ROOT, not that state's instance. + Assert.Contains(rootId, document.RootElement.GetProperty("data").GetProperty("href").GetString()!); + } + + // data does not descend: the root answers with its own humanTask block. + var (dataStatus, dataBody) = await FunctionAsync(coreUrl, "core", Root, rootId, "data", ApproverRole); + Assert.Equal(HttpStatusCode.OK, dataStatus); + using (var document = JsonDocument.Parse(dataBody)) + { + var title = document.RootElement.GetProperty("data").GetProperty("humanTask") + .GetProperty("title").GetString(); + Assert.Equal("HT-A step", title); + } + } + + /// + /// A SubProcess is authorized by ITS OWN leaf, like the independent flow it is. + /// + /// + /// It is listed by the domain that owns it and never by its parent's, so its grants can only + /// come from its own descent. Were the parent's decision reused, a caller authorized on the + /// root would inherit the SubProcess — across a domain boundary, on a case they may have no + /// part in. + /// + [SkippableFact] + public async Task ASpawnedSubProcessIsAuthorizedByItsOwnLeaf() + { + Skip.If(lab.PartnerBaseUrl is null, "partner domain not configured — run labs/cross-domain/lab.sh up"); + Skip.If(credit.CreditBaseUrl is null, "credit domain not configured — run labs/cross-domain/lab.sh up"); + + await StartAsyncAndGetIdAsync(new + { + mode = "process", + hops = 0, + processHops = 2, // SubProcess descends ht-d -> ht-e -> ht-f + testId = Guid.NewGuid().ToString("N"), + humanTask = new { title = "HT-A step", description = "HT-A step description" } + }); + + await WaitUntilAsync( + async () => (await ListHumanTasksAsync(lab.PartnerBaseUrl!, "partner", fresh: true)) + .Any(row => row.Workflow == "ht-d" && row.Title == "HT-F step"), + "the spawned SubProcess never surfaced in partner's list", + TimeSpan.FromMinutes(2)); + + // ht-f-approver is granted ONLY at the SubProcess's leaf, and it sees it. + var byLeafRole = await ListHumanTasksAsync( + lab.PartnerBaseUrl!, "partner", roles: "ht-f-approver", fresh: true); + Assert.Contains(byLeafRole, row => row.Workflow == "ht-d" && row.Title == "HT-F step"); + + // A role granted nowhere in the chain does not. + var byForeignRole = await ListHumanTasksAsync( + lab.PartnerBaseUrl!, "partner", roles: "some.other.role", fresh: true); + Assert.DoesNotContain(byForeignRole, row => row.Workflow == "ht-d"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/HumanTaskChain/README.md b/tests/Core.IntegrationTests/Tests/HumanTaskChain/README.md new file mode 100644 index 0000000..bd0fc82 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/HumanTaskChain/README.md @@ -0,0 +1,255 @@ +# human-task-chain + +## What it checks + +The `human-task` domain function lists a waiting task **under the root's identity** while taking its +**authorization decision and its text from the leaf** — however many SubFlow levels and domain +boundaries separate the two. Plus the invariant that makes the list correct in the first place: when +a blocking SubFlow finishes, the parent's whole effective projection resets, so the finished task +leaves the list and a long-poller wakes up. + +## Why it exists + +`GET /{domain}/functions/human-task` is what morph-idm-api fans out over every registered domain to +build a banker's task list. Before this scenario it had **no integration test of any kind**, while +carrying an authorization filter, a subflow descent and an unbounded fan-out. + +Four defects were found and fixed in the runtime change this scenario was written for (vnext, +2026-09-17): + +| Defect | Symptom | +|---|---| +| `EffectiveStateSubType` had no reset writer anywhere | A parent whose child had been in a Human state was offered as an open task **forever**, to every caller, on an instance that had already moved on | +| The reset that did exist was not guarded by `!HasActiveSubFlow` | With two open correlations, the state half fell back to the parent while the status half still described the surviving child | +| The descent went exactly **one** level and resolved the child's definition against the **caller's** domain | A task two levels down was authorized against the wrong state; a cross-domain child resolved to nothing and silently removed its whole instance from the list | +| A level cancelled mid-subflow kept a live child's `'A'` in the raw `EffectiveStatus` | Cancelled cases were filterable, and would have been listable, as open work | + +## The chain + +``` +ht-a (core, F) ─▶ ht-b (core, S) ─▶ ht-c (core, S) ─▶ ht-d (partner, S) ─▶ ht-e (credit, S) ─▶ ht-f (credit, S) +``` + +Depth is **data, not definition**. The start payload carries `hops`; every descent decrements it and +the `descend` rule fires only while it is positive, so one family covers every shape: + +| `hops` | Leaf | What it exercises | +|---:|---|---| +| 0 | `ht-a` | the root is its own leaf — the shape an always-descend implementation gets wrong | +| 1 | `ht-b` | one level — used by the projection-reset tests | +| 2 | `ht-c` | **Senaryo 1**: three levels, one domain | +| 3 | `ht-d` | **Senaryo 2**: one domain boundary | +| 5 | `ht-f` | **Senaryo 3**: two boundaries, the second crossed by the *partner* runtime | + +Every level writes its **own** `humanTask.title`. That is the critical part: a test asserting +`title == "HT-F step"` proves the text came from the leaf and not from the root's own data, which is +exactly what the old implementation got wrong. + +The leaf's `approve` transition grants only `ht-approver`, so a caller holding a different role must +not see the row — which proves the filter evaluated the **leaf's** transition set. + +The components are generated; edit the generator, not the JSON: + +```bash +python3 api-tests/human-task-chain/build-human-task-chain.py +python3 labs/cross-domain/encode-scripts.py core/Workflows/human-task-chain \ + partner/Workflows/human-task-chain credit/Workflows/human-task-chain +``` + +## How to run + +Senaryo 3 needs three business domains, so this scenario extended the cross-domain lab with a fourth +domain, `credit` (offset 20 → `:4221`). + +```bash +# vnext runtime images from the working tree, then the lab +cd ../vnext-example +bash labs/cross-domain/lab.sh images +bash labs/cross-domain/lab.sh up # core :4201 partner :4211 credit :4221 discovery :4231 + +dotnet test tests/Core.IntegrationTests --settings tests/Core.IntegrationTests/test.runsettings \ + --filter "FullyQualifiedName~HumanTaskChain" -v minimal +``` + +`test.runsettings` carries `VNEXT_PARTNER_BASE_URL` and `VNEXT_CREDIT_BASE_URL`. When either is +unset the scenario that needs it **skips** rather than fails, so the same suite still runs against a +single-domain stack — Senaryo 1 and both projection-reset tests need only `core`. + +## The client's side of the path + +This suite proves one domain's answer. The path a client actually takes — morph-idm-api reading +discovery's `domain-list` and merging every domain's answer — is covered by +[`api-tests/human-task-chain/`](../../../../api-tests/human-task-chain/README.md): a Python check +(20 assertions) and a hand-driven `.http` walkthrough, both against the same lab. That README also +carries the two discovery-cache traps that make a working feature look broken. + +## Pass criterion + +- Senaryo 1/2/3: exactly one row for the started root, `workflow == "ht-a"`, and `title` equal to the + **leaf's** text (`HT-C` / `HT-D` / `HT-F step`). +- A caller without `ht-approver` gets no row for that instance. +- After approving through the root, the instance **leaves** the list, and a long-poll held on the + pre-completion ETag turns from `304` into `200`. + +## Several human states in one flow + +A definition can declare several human states, and two instances of it can be waiting in different +ones at the same time. The definition says nothing about which of them matters — **only the +instance's effective position does**: + +| Instance | Where it rests | Listed with | +|---|---|---| +| started with `hops = 0` | `ht-a-human`, the root's own state | `HT-A step` | +| started with `hops = 2` | `ht-c-human`, two levels down | `HT-C step` | + +Both are rows of the same workflow, addressed by their own root, carrying different text. +`TwoInstancesOfOneFlowAreListedByWhereEachIsEffectivelyWaiting` pins exactly that. + +## The spawned SubProcess + +`ht-a` also declares `ht-a-spawn`, a plain intermediate state (`stateType: 2`, no `subFlow` block) +reached when the start payload has `mode == "process"`. Its outgoing `ht-a-spawned` transition +carries an `onExecutionTasks` entry for `ht-a-spawn-process` — a `SubProcessTask` (task `type: "14"`) +targeting `ht-d` (partner) — whose mapping (`SpawnProcessMapping.csx`) reads `processHops` off the +instance data, calls `sub.SetSync(false)` to keep the spawn fire-and-forget, and hands the executor +the same `hops`/`testId`/`humanTask` payload the old subflow mapping built. `processHops` drives how +far that SubProcess descends on its own, the same way `hops` drives the root's chain. + +A state can only start a SubFlow (`subFlow.type: "S"`); a SubProcess is started by its own task, not +by the state. A `state.subFlow.type: "P"` block — the shape this scenario used before — is rejected +at publish by the runtime's `WorkflowValidator`, so the SubProcessTask wiring above is the only valid +way to fire a SubProcess today. + +A SubProcess is an **independent flow**, so it is listed by the domain that owns it and behaves like +a root there: + +``` +core partner credit +ht-a (root, ht-a-human) ht-d (SubProcess) ─▶ ht-e ─▶ ht-f + └─ core's row: "HT-A step" └─ partner's row: "HT-F step" +``` + +Two separate rows, in two separate domains' answers — morph-idm fans out over both and shows both. +The SubProcess's row is addressed by its **own `id`**, never by `instanceId`'s usual business key: +`SubflowStarter` copies the parent's `Key` onto the child, so following that key would land a client +on the root instead. `ASpawnedSubProcessIsListedOnItsOwnAndDescendsLikeARoot` pins both halves — the +identity and the fact that the SubProcess descended two further levels to get its text. + +## Authorization + +A wrong answer here is a banker seeing another banker's case, and the failure is invisible in the +response by construction — an unauthorized row is simply absent. Every authorization test is +therefore a **pair**: something the caller must see and something the same caller must not, from the +same data in the same call. A test that only asserts absence cannot tell "correctly filtered" from +"the descent dropped it", which is exactly how this scenario's first negative test turned out to be +vacuous. + +### The gate is the leaf state's `queryRoles` + +The list answers *"which human tasks am I responsible for?"* — a **visibility** question. Which button +is offered is settled later, when the client opens the instance. Each level's human state therefore +declares `queryRoles`, and those are what the list is decided by: + +| Grant | Pins | +|---|---| +| `ht-approver` allow | the broad role, granted at **every** level | +| `{level}-approver` allow | **only** that level — a caller holding just `ht-f-approver` sees a chain resting on `ht-f` and not one resting on `ht-c`, which is what proves the decision came from the LEAF | +| `ht-blocked` deny | DENY refuses whatever else the caller carries | + +The levels still author transition `roles` too, with the same three grants. They no longer influence +the list — they govern what the client is **offered on open**, which is the other half of the split +and is asserted on the state function instead. + +### The parent's overrides are live, and each is asserted on its own surface + +`ht-d`'s subflow state overrides `ht-e`: + +```json +"overrides": { + "states": { "ht-e-human": { "queryRoles": [ xd-override-only allow, ht-blocked deny ] } }, + "transitions": { "ht-e-approve": { "roles": [ xd-override-only allow ] } } +} +``` + +Authored on the `ht-d → ht-e` link for two reasons: no other test rests on `ht-e`, so no existing +scenario changes meaning; and that link crosses a **domain boundary** (partner → credit), so the +stamp has to survive a cross-domain start to be readable at the leaf at all. + +| Override | Asserted on | Test | +|---|---|---| +| `states` | the **human-task list** — `xd-override-only` sees the chain resting on `ht-e`, `ht-approver` (which `ht-e` itself grants) does not, and a level the parent did not override still answers to its own grants | `AParentsStampedStateOverrideNarrowsTheChildsVisibility` | +| `states` + DENY | the list — `xd-override-only,ht-blocked` is refused | `ADenyInTheStampedStateOverrideRefuses` | +| `transitions` | the **overriding parent's state function** — the roles `ht-e` itself grants are no longer offered `ht-e-approve`, while the identical un-overridden shape one level up still offers `ht-d-approve` to exactly those roles | `AParentsTransitionOverrideStillGovernsWhatTheLeafOffers` | + +Splitting the two across surfaces is the point: visibility is the list's question, actionability is +the screen's, and asserting both on one surface would hide a regression in the other. + +**Both halves of one `overrides` block now behave the same on every surface.** The first version of +this change applied the transition override on the state function's subflow bubbling but not the +state override, so a caller holding only `xd-override-only` was refused at `ht-d` while the +transition narrowing was clearly in force. The stamped state override is now read inside +`TransitionAuthorizationManager.IsQueryAllowedAsync`, the single queryRoles gate behind every read +surface, so the parent's narrowing applies wherever the child is reached from. Measured on `ht-d`: + +``` +x-roles: xd-override-only → state=ht-e-human, transitions=[ht-e-approve] +x-roles: ht-approver → state=ht-d-subflow, transitions=[] (replaced, not merged) +x-roles: ht-e-approver → state=ht-d-subflow, transitions=[] +x-roles: xd-override-only,ht-blocked → state=ht-d-subflow, transitions=[] (DENY wins) +``` + +### DENY refuses whatever else the caller carries + +`authorized = AllowGroup AND DenyGroup`: allows OR among themselves, denies AND among themselves, and +the deny group is evaluated first. One breached deny refuses outright. + +This reverses what this scenario pinned a day earlier. The rule used to be applied per caller role +inside a loop that returned on the first role that was allowed, so a deny for role B was never reached +once role A had matched an allow — `[approver, blocked]` passed, and a deny grant was unusable as a +block. `ADenyGrantRefusesWhateverElseTheCallerCarries` pins the new rule in both orders, because +"evaluated first" is what makes the answer independent of how the caller's roles happen to be listed. + +### The response cache is part of the authorization surface + +`TheResponseCacheDoesNotServeOneCallersListToAnother` is the only test here that deliberately does +**not** send `X-VNext-Cache-Override`. Every other one does, so none of them exercises the cached +path at all — and this is the one place where a cache-key mistake stops being a stale answer and +becomes a data leak. It warms the entry for the broad role, then reads as a different caller inside +the TTL, then reads again as the first caller to prove the second call neither evicted nor poisoned +the entry. + +## Why some rows have no title + +A row's text is whatever the **leaf** carries in `humanTask.title` / `.description`. A flow that +never writes that block produces a correctly-listed row with empty text — `cross-domain-lab`'s +`xd-parent` is the example in this repo: its `xd-child` has a `subType: 6` state (`child-review`) and +no `humanTask` data anywhere, so the rows are real waiting tasks with nothing to display. An empty +title means "the flow wrote no task text", never "the descent failed" — a failed descent drops the +row and is counted (`vnext.humantask.dropped`, `WorkflowLogs` 20450-20456). + +## Polling and the response cache + +Every wait loop and every assertion sends **`X-VNext-Cache-Override: true`**. The function's response +cache has a 60 s TTL and no validation query, so a test that polls through it waits out the TTL on a +pre-change answer and then reports a wrong list rather than a stale one — which is a different, and +misleading, failure. Reading fresh is also what a client is expected to do at a moment it cannot +tolerate the TTL, so the header is exercised end to end here rather than only in unit tests. + +## Known limits + +- The chain uses no HTTP tasks, so MockLab is not required. +- `hops` is trusted as given; there is no upper bound in the definition. The runtime's own + `HumanTaskFunction:MaxDescentDepth` (default 10) is what bounds the descent, and a chain longer + than that is reported as unresolved rather than as "no task here". +- Senaryo 3 is the only test that needs `credit`. If the lab is brought up without it, that one test + skips and the other five still prove the same-domain and one-boundary paths. +- **The SubProcess test no longer needs to start the root asynchronously.** The old shape — + `ht-a-spawn` as a state-level SubProcess (`subFlow.type: "P"`) followed by an automatic transition + — could not be started with `sync=true`: the post-commit `ContinueParent` continuation re-entered + the pipeline with `IsPreReserved = false` while the instance was still Busy from the very stage that + continuation belonged to, so admission rejected it with `conflict.Instance:100031` and the parent + was stranded in the spawn state with an orphaned child. That shape is now rejected outright at + publish time by the runtime's `WorkflowValidator` (a state may only start a SubFlow, never a + SubProcess), and the fix — spawning `ht-d` through the `ht-a-spawn-process` SubProcessTask on the + `ht-a-spawned` transition instead of through the state — does not re-enter the pipeline the same + way, so the sync-path 409 no longer applies and the async-only workaround is no longer needed. diff --git a/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/README.md b/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/README.md new file mode 100644 index 0000000..c37e1e2 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/README.md @@ -0,0 +1,125 @@ +# SubflowStartFailureLab — integration test + +One test class, **two tests**, over `subflow-start-failure-lab-parent` (domain `core`). + +| Class | Test | What it checks | +|---|---|---| +| `SubflowStartFailureLabTests` | `AFailedSubflowStartFaultsTheParentInsteadOfStrandingItBusy` | A blocking SubFlow (`subFlow.type: "S"`) whose child fails to start post-commit: does the runtime fault the parent (and NOT leave it stranded `Busy`), with an active incident recorded? **GREEN — proven.** | +| `SubflowStartFailureLabTests` | `RetryingAFaultedSubflowStartRecoversTheInstance` | Does retry (with the corrected field supplied) actually recover that faulted instance — create the missing child and resume waiting on it? **GREEN — proven.** | + +## Why this exists + +A code review on `fix/stranded-busy-and-dead-flag-cleanup` raised a CRITICAL against +`TransitionRunner.CompensateFailedCoordinationAsync`: when the post-commit `StartSubflowJob` fails, +the runtime faults the parent, documented as making the instance "visible and **retryable**". But +`HandleSubFlowStep` (order 70) commits the `InstanceCorrelation` in its own unit of work strictly +*before* the post-commit job that would actually create the child ever runs. So a child that was +never created is indistinguishable, from the correlation alone, from a live one. + +**Originally measured (the gap).** `InstanceRetryAppService.RetryFaultedInstanceAsync` branched on +`instance.HasActiveSubFlow` — true here, because the correlation exists — straight into unfaulting +the parent (committing that unfault and resolving its incidents), and only *then* queried the +child's state through `IInstanceQueryGateway`. That child did not exist, so the query failed and the +retry request failed too — after the parent had already been unfaulted: `HTTP 404 Instance:100013` +naming the never-created child, with the parent left neither faulted (a second retry refused as "not +faulted") nor making progress, its fault history erased. + +No test anywhere exercised this path before this lab. The scenario was originally written as a +single test asserting "fault, then retry recovers" — that test was red, so it was split into a green +proof of the fault/incident half and a green, intentionally narrow pin of the retry gap, written to +fail loudly the day the gap closed. + +**The gap has since been closed in the runtime.** `InstanceRetryAppService` now probes the child +BEFORE touching the parent; when the probe answers "not found" it re-arms the parent (unfault, then +Busy — mirroring the invariant a live blocking SubFlow parent always has) and restarts the subflow +start for the SAME correlation (idempotent on the correlation's own pre-generated +`SubFlowInstanceId`, via `ISubflowStarter`'s `StrictIdempotency`) instead of delegating to a child +that never existed. `RetryingAFaultedSubflowStartRecoversTheInstance` now asserts that recovery +directly; the two tests stay split because they exercise genuinely different invariants +(fault-visibility vs. retry-recovery), not because either is still red. + +**Known-issue tracking.** No `vnext-meta` known-issues row should exist for +`failed-subflow-start-is-faulted-but-not-retryable` going forward — the gap it would have described +is fixed. `pre-reserved-job-failure-can-strand-busy` remains a separate, already-fixed failure mode +(the stranded-Busy case `AFailedSubflowStartFaultsTheParentInsteadOfStrandingItBusy` pins). +This test suite (vnext-example) does not itself edit the runtime repo. + +## The fixture + +`core/Workflows/subflow-start-failure-lab/`, generated by `build-subflow-start-failure-lab.py` from +`./src/*.csx` (edit the `.csx`, rerun the script, never hand-edit the base64 blobs): + +- `subflow-start-failure-lab-parent.json` — `parent-initial` auto-transitions into + `parent-subflow-state` (`stateType: 4`, `subFlow.type: "S"` — a state-level `"P"` is rejected at + publish, see `WorkflowValidator`), whose `subFlow.process` points at + `subflow-start-failure-lab-child@1.0.0`. +- `subflow-start-failure-lab-child.json` — a real, resolvable, separately published child + (`attributes.type: "S"`) whose start transition carries a schema + (`core/Schemas/subflow-start-failure-lab/subflow-start-failure-lab-child-start.json`) requiring + `mustProvide`. The child has exactly one state (`child-initial`, no transitions) — it never + completes on its own, so a successfully-started child stays observable as `state="child-initial"`, + `status="A"` for as long as the test needs it. +- `src/ParentToChildSubFlowMapping.csx` never sets `mustProvide` from the parent's own instance data + (`context.Instance.Data`) — that is what makes the ORIGINAL, automatic subflow start fail. It DOES + read `mustProvide` from `context.Body` when present, which is what lets a corrected retry succeed + (see "Why the retry now succeeds" below). + +**Form tried first, and why it was abandoned.** The cheapest failure form is `subFlow.process` +pointing at a real child key (`subflow-orchestration-child`) at a version that was never published +(`9.9.9`). `wf sync` accepted that at publish time — no reference-consistency check walks into a +`subFlow.process` version. But starting the **parent** itself then failed synchronously with a plain +404 (`Workflow not found in runtime backend`, target `core/subflow-orchestration-child@9.9.9`): +`IComponentCacheStore` resolves the *whole* reachable component graph — every `subFlow.process` +included — when the parent's own definition loads, so the parent never reached `HandleSubFlowStep` +and no correlation was ever committed. That does not reproduce the bug under test (a correlation +committed *before* the child-start failure), so this lab uses the schema-validation form instead, +which fails only when the child's own start transition actually runs, post-commit. + +**Sync vs async start — measured, not assumed.** The parent must be started with `sync=false`. +`WorkflowTestBase.StartAsync` cannot be used here: it goes through the SDK's +`VNextApiClient.StartInstanceAsync`, which hard-codes `sync=true`. Measured directly: starting the +parent with `sync=true` blocks on the whole pipeline, including the failing post-commit child start, +and the **child's** schema-validation error is returned as the top-level response to the **parent's** +own start call (HTTP 400, `"Required properties [\"mustProvide\"] are not present"`) — even though +the parent instance is *also* faulted server-side, by the same compensation path, in that same call. +That is sync mode's blocking semantics leaking the nested failure to the wrong caller, not the +scenario under test. With `sync=false` — the production default for a client-facing start, and the +exact shape the CRITICAL assumes ("when a post-commit StartSubflowJob fails, the runtime faults the +parent") — the start call returns 202 immediately and the fault happens in the background, observable +only by polling. The test's local `StartParentAsync` helper posts to `.../instances/start?sync=false` +directly for this reason. + +**Why the retry now succeeds.** `mustProvide` is never supplied via `Instance.Data`, so a bare retry +with no body would fail again, identically, for the identical reason — that is not "recovery", it is +the same fixture failing the same way twice. `RetryingAFaultedSubflowStartRecoversTheInstance` +instead supplies the corrected field ON THE RETRY REQUEST itself +(`{"attributes":{"mustProvide": "..."}}`), the realistic shape of an operator fixing bad input and +retrying. `InstanceRetryAppService`'s subflow-restart path threads the retry's `data.attributes` +through to a freshly built `TransitionExecutionContext.Data`, which `StartSubflowJobHandler` feeds +to `ScriptContext.Body` exactly the way any other transition's `OnExecute` tasks would see +retry-supplied data — so `ParentToChildSubFlowMapping.csx`'s `context.Body.mustProvide` read now +finds the value and the child's schema validation passes. + +## What each test asserts, in order + +### `AFailedSubflowStartFaultsTheParentInsteadOfStrandingItBusy` (green proof — fault/incident) + +1. Start the parent asynchronously; wait for `status == "F"` within 60s. This alone proves the new + fault arm fires end to end when a post-commit child start fails schema validation, and explicitly + asserts the status is not `"B"` (the pre-fix stranded-Busy failure mode). +2. `GET .../incidents/active` returns 200 — an incident exists — and its `errorCode`/`message` are + captured and asserted non-empty, quoted in every failure message so a reader sees the real cause + without re-running anything. + +### `RetryingAFaultedSubflowStartRecoversTheInstance` (green proof — retry recovery) + +1. Same fault-then-incident setup as above. +2. `POST .../retry?sync=true` with the corrected `mustProvide` field in the body. Assert the response + is `< 400`. +3. `GET` the parent's own instance record (no descent): status is no longer `"F"`, and it remains in + `parent-subflow-state` (still parked at the SubFlow state, correlation open, now waiting on a live + child rather than a dead one). +4. Call the **state function** (which, unlike the plain instance GET, descends into the active + SubFlow correlation): it now reports the child's own state directly — + `state="child-initial"`, `status="A"` — proving the child instance was actually created and is + running, not merely that the HTTP call returned success. diff --git a/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/SubflowStartFailureLabTests.cs b/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/SubflowStartFailureLabTests.cs new file mode 100644 index 0000000..84160d3 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/SubflowStartFailureLab/SubflowStartFailureLabTests.cs @@ -0,0 +1,274 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.SubflowStartFailureLab; + +/// +/// subflow-start-failure-lab: what happens when the post-commit StartSubflowJob for a +/// blocking SubFlow state (subFlow.type: "S") can never succeed, because the child's own +/// start transition fails schema validation — a field its schema requires +/// (mustProvide) that the parent's ISubFlowMapping input handler deliberately never +/// supplies. +/// +/// +/// +/// Why this exists. A code review on the sibling branch (stranded-busy-and-dead-flag-cleanup) +/// raised a CRITICAL against TransitionRunner.CompensateFailedCoordinationAsync: when a +/// post-commit StartSubflowJob fails, the runtime now faults the parent, and the fault is +/// documented as making the instance "visible and retryable". But HandleSubFlowStep (order +/// 70) commits the InstanceCorrelation in its OWN unit of work, strictly before the +/// post-commit job that would actually create the child ever runs. So by the time the fault lands, +/// the parent already carries a correlation pointing at a child that was never created. +/// +/// +/// Measured outcome — two tests. Running this scenario against the runtime showed the +/// fault/incident half of the claim holds +/// (). Retry originally made +/// things WORSE — HTTP 404 notfound.Instance:100013 naming the never-created child, parent +/// left unfaulted but stuck in parent-subflow-state with no progress and its fault history +/// erased. That specific failure mode is CLOSED in the runtime: InstanceRetryAppService now +/// probes the child BEFORE touching the parent and, when it is missing, re-arms the parent (unfault, +/// then Busy) and restarts the subflow start for the SAME correlation instead of delegating to a +/// child that never existed. This fixture's cause (a permanently missing required field the parent's +/// mapping never supplies) cannot self-heal from a faithful restart carrying no new data — "a re-run +/// of a start should look like that start" — so the second test +/// () +/// proves the invariant that actually matters for a restart that fails AGAIN: the parent comes back +/// around to Faulted with a FRESH incident, never stranded Busy with neither an incident nor a live +/// child. Both tests are needed because they pin genuinely different, independently useful +/// invariants (fault-visibility on the original failure vs. no-stranding on a failed retry). +/// +/// +/// The fixture form. The cheapest failure form was tried first: subFlow.process +/// pointing at a real child key (subflow-orchestration-child) at a version that was never +/// published (9.9.9). wf sync accepted that at publish time. But starting the PARENT +/// then failed synchronously with a plain 404 ("Workflow not found in runtime backend", target +/// core/subflow-orchestration-child@9.9.9) — IComponentCacheStore resolves the whole +/// reachable component graph, including every subFlow.process, when the PARENT's own +/// definition loads, so the parent never even reached HandleSubFlowStep and no correlation +/// was ever committed. That does not reproduce the bug under test, so this lab uses its own child +/// workflow (subflow-start-failure-lab-child, a real, resolvable, published component) whose +/// start transition carries a schema requiring mustProvide — a field the parent's mapping +/// never sends. That failure surfaces only when the CHILD's own start actually runs, post-commit, +/// which is exactly the shape the CRITICAL describes. +/// +/// +/// Sync vs async start. The parent MUST be started asynchronously (sync=false), not +/// through (the SDK client hard-codes sync=true). +/// Measured directly: with sync=true the client's own start request blocks on the whole +/// pipeline including the failing post-commit child start, and the CHILD's schema-validation error +/// is returned as the top-level response to the START call itself (HTTP 400, "Required properties +/// [\"mustProvide\"] are not present") — even though the parent instance is ALSO faulted +/// server-side in the same call, by the same compensation path. That is an artifact of sync mode's +/// blocking semantics, not the scenario under test. With sync=false (the production default +/// for a client-facing start, and the exact shape the CRITICAL assumes: "when a post-commit +/// StartSubflowJob fails, the runtime faults the parent"), the start call returns 202 immediately +/// and the fault happens in the background — observable only by polling, which is what both tests +/// below do. +/// +/// +public class SubflowStartFailureLabTests : WorkflowTestBase +{ + private const string Parent = "subflow-start-failure-lab-parent"; + + public SubflowStartFailureLabTests(VNextTestEnvironment environment) : base(environment) { } + + /// + /// Pins the half of the CRITICAL's claim the runtime change actually delivers: a post-commit + /// child-start failure faults the parent, and the fault is visible through an active incident — + /// instead of leaving the instance stranded Busy forever. + /// + /// + /// Before this runtime change, a failed post-commit StartSubflowJob ran neither + /// settlement nor fault: HandleSubFlowStep (order 70) had already committed the + /// InstanceCorrelation and set the instance Busy for the subflow's lifetime, and + /// nothing ever flipped it back. The parent sat Busy with no incident and no fault + /// recorded — invisible to every query surface that reports on faulted/active work, and + /// recoverable only by a direct database intervention (there being no faulted instance for + /// retry to act on, and no way to distinguish it from an instance genuinely still doing + /// work). This test proves that strand no longer happens: the parent reaches Faulted + /// within a bounded wait, carries an active incident whose error naming the real cause, and is + /// specifically NOT left Busy. + /// + [Fact] + public async Task AFailedSubflowStartFaultsTheParentInsteadOfStrandingItBusy() + { + // 1) Start the parent ASYNCHRONOUSLY. Its initial state auto-transitions straight into the + // blocking SubFlow state; HandleSubFlowStep (order 70) commits the InstanceCorrelation in + // its own UoW, and only then does the post-commit StartSubflowJob call the child's start + // transition — which fails schema validation ("mustProvide" is required, never supplied). + var parentId = await StartParentAsync(new { testId = $"start-failure-{Guid.NewGuid():N}"[..24] }); + + await WaitUntilAsync(async () => + { + var (_, status) = await GetInstanceStateAsync(Parent, parentId); + return status == "F"; + }, $"the parent never faulted after the child's start failed schema validation — was it " + + $"left stranded Busy instead? {await DescribeAsync(Parent, parentId)}", TimeSpan.FromSeconds(60)); + + var (faultedState, faultedStatus) = await GetInstanceStateAsync(Parent, parentId); + Assert.Equal("F", faultedStatus); + Assert.NotEqual("B", faultedStatus); // explicit: not stranded Busy — the pre-fix failure mode + + // 2) An incident must exist — the "visible" half of the claim. Quote its error code and + // message in every failure this assertion can produce, so a reader sees the real cause + // without re-running anything. + var (incidentStatus, incidentBody) = await GetActiveIncidentAsync(Parent, parentId); + var errorCode = TextOrDash(incidentBody, "errorCode"); + var incidentMessage = TextOrDash(incidentBody, "message"); + + Assert.True(incidentStatus == HttpStatusCode.OK, + $"expected an active incident on the faulted parent (state={faultedState}), " + + $"got {(int)incidentStatus}: {incidentBody.GetRawText()}"); + Assert.True(incidentStatus != HttpStatusCode.OK || !string.IsNullOrEmpty(errorCode), + $"incident found but carried no errorCode/message — errorCode='{errorCode}' " + + $"message='{incidentMessage}'"); + } + + /// + /// Proves the invariant a retry-driven subflow restart must never violate, for the case where + /// the restart itself CANNOT succeed: the parent must come back around to Faulted with a + /// fresh incident, never left stranded Busy with neither an incident nor a live child. + /// + /// + /// + /// What used to happen. HandleSubFlowStep commits the InstanceCorrelation in + /// its own unit of work strictly before the post-commit job that would actually create the child + /// ever runs — so a child that was never created was indistinguishable, from the correlation + /// alone, from a live one. The original InstanceRetryAppService.RetryFaultedInstanceAsync + /// branched on instance.HasActiveSubFlow — true here — straight into unfaulting the parent + /// (committing that unfault and resolving its incidents) and only THEN querying the child's + /// state. That child did not exist, so the query failed and the retry request failed too, AFTER + /// the parent had already been unfaulted: HTTP 404 Instance:100013 naming the never-created + /// child, with the parent left neither Faulted (refusing a second retry) nor making progress — + /// worse than before the call. + /// + /// + /// The fix. The child's absence is now probed BEFORE the parent is touched. When the + /// probe comes back "not found", InstanceRetryAppService re-arms the parent (unfault, then + /// Busy — mirroring the SubFlow-lifetime invariant a live blocking child always has, under the + /// same short status lock every other status flip uses) and restarts the subflow start for the + /// SAME correlation — idempotent on its own pre-generated SubFlowInstanceId + /// (ISubflowStarter's StrictIdempotency), so this would create the child the + /// correlation always pointed at rather than a second one, and it never re-runs the parent's own + /// transition (whose tasks already ran once). + /// + /// + /// Why THIS retry cannot recover, on purpose. mustProvide is never supplied from + /// the parent's own instance data (ParentToChildSubFlowMapping.csx), and a restart + /// deliberately carries no new caller data of its own — "a re-run of a start should look like + /// that start" (a caller-data-threading mechanism was considered and rejected as scope creep + /// beyond this fix). So a bare retry against THIS fixture fails again, identically, for the + /// identical reason: a fully faithful re-run of a permanently misconfigured mapping cannot + /// self-heal, and it is not supposed to. What matters — and what this test actually proves — is + /// that failing again does not stand for anything worse than "still faulted, still retryable": + /// no strand, no silent Busy, no swallowed error. + /// + /// + /// Measured result. The retry call itself fails (HTTP ≥ 400: the restart's own error, not + /// a 5xx crash). The parent is re-faulted — Faulted, still in parent-subflow-state + /// — carrying a NEW incident (a different id than the one the original fault raised: the + /// old one really was resolved during the re-arm, and this is a fresh one from the failed + /// restart, not the same stale row left behind). Postgres confirms the parent's raw status, + /// its correlation's SubFlowInstanceId (unchanged — no second correlation was created), + /// and that no row exists yet in the child schema for it. + /// + /// + [Fact] + public async Task RetryingAFaultedSubflowStartThatCannotSucceed_ReFaultsInsteadOfStrandingTheParent() + { + var parentId = await StartParentAsync(new { testId = $"start-failure-{Guid.NewGuid():N}"[..24] }); + + await WaitUntilAsync(async () => + { + var (_, status) = await GetInstanceStateAsync(Parent, parentId); + return status == "F"; + }, $"the parent never faulted after the child's start failed schema validation — " + + $"{await DescribeAsync(Parent, parentId)}", TimeSpan.FromSeconds(60)); + + var (firstIncidentStatus, firstIncidentBody) = await GetActiveIncidentAsync(Parent, parentId); + Assert.True(firstIncidentStatus == HttpStatusCode.OK, + $"expected an active incident on the freshly faulted parent, got {(int)firstIncidentStatus}: {firstIncidentBody}"); + var firstIncidentId = TextOrDash(firstIncidentBody, "id"); + + // Bare retry, no corrected data — a faithful re-run of the exact same (permanently broken) + // start. It MUST fail again, for the identical reason; that is not a bug, see remarks. + var (retryStatus, retryBody) = await RetryAsync(Parent, parentId); + + Assert.True((int)retryStatus >= 400, + $"expected the restart to fail again (same missing 'mustProvide', no data changed to fix " + + $"it), got HTTP {(int)retryStatus}: {retryBody}"); + + // The invariant under test: a restart that fails must not strand the parent. It has to come + // back around to Faulted — re-armed Busy, attempted, and re-faulted — within a bounded wait. + await WaitUntilAsync(async () => + { + var (_, status) = await GetInstanceStateAsync(Parent, parentId); + return status == "F"; + }, $"the parent was left stranded (not re-faulted) after its subflow restart failed again — " + + $"{await DescribeAsync(Parent, parentId)}", TimeSpan.FromSeconds(30)); + + var (finalState, finalStatus) = await GetInstanceStateAsync(Parent, parentId); + Assert.Equal("F", finalStatus); + Assert.Equal("parent-subflow-state", finalState); + + var (secondIncidentStatus, secondIncidentBody) = await GetActiveIncidentAsync(Parent, parentId); + var secondErrorCode = TextOrDash(secondIncidentBody, "errorCode"); + Assert.True(secondIncidentStatus == HttpStatusCode.OK, + $"expected a fresh active incident after the re-fault, got {(int)secondIncidentStatus}: {secondIncidentBody}"); + Assert.False(string.IsNullOrEmpty(secondErrorCode), + $"incident found but carried no errorCode — {secondIncidentBody}"); + + var secondIncidentId = TextOrDash(secondIncidentBody, "id"); + Assert.NotEqual(firstIncidentId, secondIncidentId); + } + + // ── local helpers ──────────────────────────────────────────────────────── + // None of these have an SDK method or a home in WorkflowTestBase yet — all raw HTTP, matching + // the pattern in Tests/ErrorBoundaryLab/ErrorBoundaryLabTestBase.cs. StartParentAsync exists + // because WorkflowTestBase.StartAsync goes through VNextApiClient.StartInstanceAsync, which + // hard-codes sync=true — wrong for this scenario, see the class remarks. + + /// Starts the parent with sync=false and returns its instance id. + private async Task StartParentAsync(object body) + { + var url = $"api/v1/core/workflows/{Parent}/instances/start?sync=false"; + var (status, responseBody) = await SendRawAsync(HttpMethod.Post, url, body, Headers()); + + Assert.True((int)status < 400, $"start was refused with {(int)status}: {responseBody}"); + + using var document = JsonDocument.Parse(responseBody); + return document.RootElement.GetProperty("id").GetString() + ?? throw new InvalidOperationException("start response carried no instance id"); + } + + private async Task<(HttpStatusCode Status, JsonElement Body)> GetActiveIncidentAsync( + string workflow, string instanceId) + { + var url = $"api/v1/core/workflows/{workflow}/instances/{instanceId}/incidents/active"; + var (status, body) = await SendRawAsync(HttpMethod.Get, url, headers: Headers()); + return (status, ParseOrEmpty(body)); + } + + /// POST .../instances/{id}/retry. sync=true so the response reflects the + /// outcome of the retried transition directly, the same way IncidentLifecycleTests does it. + /// is the optional retry-supplied TransitionDataInput body (e.g. + /// {"attributes": {...}}) — the corrected data a real operator would supply. + private Task<(HttpStatusCode Status, string Body)> RetryAsync( + string workflow, string instanceId, object? body = null) + { + var url = $"api/v1/core/workflows/{workflow}/instances/{instanceId}/retry?sync=true"; + return SendRawAsync(HttpMethod.Post, url, body ?? new { }, Headers()); + } + + private static string TextOrDash(JsonElement element, string property) => + element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "-" + : "-"; + + private static JsonElement ParseOrEmpty(string body) => + string.IsNullOrWhiteSpace(body) ? default : JsonDocument.Parse(body).RootElement.Clone(); +} diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ErrorBoundaryInteractionTests.cs b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ErrorBoundaryInteractionTests.cs new file mode 100644 index 0000000..a868eb2 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ErrorBoundaryInteractionTests.cs @@ -0,0 +1,207 @@ +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.TaskInvocationLab; + +/// +/// Proves the error boundary still selects the right handler from a task's RESULT when that task +/// ran on the in-process (Local) invocation path — the second of the three claims this scenario +/// exists for (vnext issue #1007). All three boundaries here are declared at task level on the +/// failing task's own onExecutionTasks entry (see task-invocation-lab.json), never +/// at state or workflow level, so boundaryLevel is always expected to be Task. +/// +/// +/// +/// The timeout case is wired through errorTypes, not errorCodes and not +/// onTimeout — both by necessity, confirmed empirically against the live incident rows, +/// not by reading source alone. +/// +/// errorBoundary.onTimeout is schema-valid, publishes fine, and is never read by +/// CompiledBoundary.Compile — a timeout handler authored the obvious way (onTimeout) +/// silently does nothing. An HTTP client timeout is not raised to the boundary as a distinguishable +/// "timeout" signal at all: it comes back as an ordinary TaskInvocationResult.Failure with +/// StatusCode: null and Metadata["ExceptionType"] = "TaskCanceledException" — a .NET +/// type name, not a timeout-shaped code (httpClient.Timeout expiring races, not cancels, the +/// caller's own CancellationToken, so HttpTaskInvocation's cancellation-branch guard, +/// when (cancellationToken.IsCancellationRequested), does not apply, and the exception falls +/// to the generic catch instead — the message text, "The request was canceled due to the configured +/// HttpClient.Timeout of 2 seconds elapsing", is the one positive proof that the in-process path +/// DOES honor the task's own timeoutSeconds, which is one of the things this lab exists to +/// verify). +/// +/// +/// Two DIFFERENT matching mechanisms exist, keyed off two DIFFERENT fields of the same +/// NormalizedError, and this failure only satisfies one of them. The business-failure +/// branch in TaskExecutionEngine.ExecuteCoreAsync normalizes the failure via +/// ErrorNormalizer.NormalizeTaskResponse, which sets .Code to +/// Task:{TaskType}:{TaskKey}[:{StatusCode}] (no exception type, ever) and separately sets +/// .ExceptionType from Metadata["ExceptionType"]; .OriginalCode is left null +/// (it is populated only from a Metadata["ErrorCode"] key that HttpTaskInvocation +/// never sets). With no Retry rule present, resolution always falls through to +/// TaskExecutionEngine.HandlePostRetryFailureAsync's +/// _boundaryResolver.ResolveExcluding(...), which resolves via +/// CompiledBoundaryChain.FindMatchExcluding(NormalizedError error, ...) — and THAT overload +/// matches on error.OriginalCode (always null here) and error.ExceptionType +/// ("TaskCanceledException"), not error.Code. A rule with only errorCodes +/// populated is therefore structurally unable to match this failure: ErrorHandlerRule +/// .MatchesAnyCode needs a non-null errorCode or a non-null statusCode to compare +/// against, and both are null for a client-side timeout — no string placed in errorCodes +/// (short form, full composite form, or the raw exception-type text) can ever satisfy it, and even +/// the wildcard-style catch-all confirms the boundary compiles and the pipeline mechanics are fine +/// once a matching rule exists. Only errorTypes reaches ErrorHandlerRule +/// .MatchesExceptionType, which compares against error.ExceptionType — the one field +/// that IS reliably "TaskCanceledException" for this failure. til-http-slow's boundary is +/// therefore authored as errorTypes: ["TaskCanceledException"], not errorCodes. +/// +/// +/// Verified against the persisted incident row, not just the test's own assertions ( +/// select "Task","ErrorCode","BoundaryAction" from task_invocation_lab."InstanceIncidents" +/// where "Task" = 'til-http-slow' order by "CreatedAt" desc limit 1): with +/// errorTypes: ["TaskCanceledException"], the newest row reads +/// BoundaryAction = 'Rollback' and the instance's CurrentState = 'rolled-back', +/// Status = 'C'. With any errorCodes-only variant (including the exact composite +/// string Task:Http:til-http-slow:TaskCanceledException that a MATCHED rule never actually +/// needs to reproduce — see below), BoundaryAction stays NULL and the instance faults in +/// place at ready. Publishing changes to a live runtime while iterating on this rule +/// requires bumping the component's own version field each time — the SDK's/`wf update`'s +/// publish is a per-version no-op on a version it has already seen, which produced several +/// misleading "still fails" results while this file's version sat unchanged across edits. +/// +/// +/// This is also why the incident's displayed errorCode differs depending on whether a +/// rule matched. BoundaryOutcomeHandler.BuildIncident (the matched path) reads +/// error.NormalizedError.Code directly — the SHORT form, Task:Http:til-http-slow, no +/// exception type — which is what a matched incident's row actually shows. Only on the UNMATCHED +/// path does TaskCoordinator.ProcessTaskResult re-normalize the already-composed +/// Error from ExecutionError.ToError() (which DOES append :{ExceptionType}) +/// through CreateFromError — whose BuildTaskCode sees a code that already starts with +/// "Task:" and returns it verbatim — which is why an UNMATCHED incident's errorCode +/// looks like Task:Http:til-http-slow:TaskCanceledException, a string the boundary was never +/// actually offered to match against. This test therefore asserts the SHORT form. +/// +/// +/// Authoring gap worth flagging (see the report for the issue writeup): combined with +/// onTimeout being inert, there is currently no discoverable, generically-writable way for a +/// domain author to handle an HTTP task's own timeout. The only string that matches is the .NET +/// exception type name via errorTypes — undocumented, and indistinguishable at the schema +/// level from an errorCodes entry, so a plausible-looking errorCodes: +/// ["TaskCanceledException"] silently does nothing (confirmed above). errorTypes: ["*"] +/// is the only fully generic alternative, and it catches every exception-driven failure, not just a +/// timeout. +/// +/// +/// This class does not assert retryCount — none of these boundaries retry (Notify, +/// Rollback and Abort are all terminal-per-attempt actions) — so, unlike +/// ErrorBoundaryLab.RetryPolicyTests, there is nothing here to measure that field against. +/// +/// +public class ErrorBoundaryInteractionTests : TaskInvocationLabTestBase +{ + public ErrorBoundaryInteractionTests(VNextTestEnvironment environment) : base(environment) { } + + [SkippableFact] + public async Task Http500_NotifyBoundary_LandsNotified_WithIncident() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-http-500"); + + var (state, status) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal("notified", state); + Assert.NotEqual("F", status); + + var incident = BoundaryIncident(await GetIncidentItemsAsync(instanceId)); + Assert.Equal("Notify", Text(incident, "boundaryAction")); + Assert.Equal("Task", Text(incident, "boundaryLevel")); + Assert.Equal(500, Number(incident, "statusCode")); + + // FinalizeTransitionStep.ResolveIncidentOnSuccessfulErrorBoundaryTransition: an + // error-boundary transition (this one — RequestNextTransition("to-notified", + // TransitionRequestReasons.ErrorBoundary)) that completes WITHOUT a new fault resolves + // every incident the failure left open, by design ("the boundary transition landed, the + // failure is handled"). The Notify incident is therefore already resolved by the time the + // instance lands on `notified` — confirmed by reading the raw incident (`isResolved: true, + // resolvedAt: `) and `metadata.incident` (`hasActiveIncident: false`, no `active` key). + var metadata = await GetIncidentMetadataAsync(instanceId); + Assert.NotNull(metadata); + Assert.False(Flag(metadata!.Value, "hasActiveIncident"), + "a Notify boundary that routes to a transition resolves its own incident once that " + + "transition lands without faulting"); + AssertAbsent(metadata.Value, "active", "resolved incidents carry no active-incident link"); + + // The failing task's own OutputHandler still ran (same pattern as + // ErrorBoundaryLab.ContinueActionsTests observing `httpAttempts` on a failed attempt): the + // projection reflects the 500, not the transition's declared (and overridden) `landed` target. + var projection = await GetProjectionAsync(instanceId); + Assert.Equal("case-http-500", NullableString(projection, "tilCase")); + Assert.Equal(500, NullableLong(projection, "tilStatusCode")); + } + + [SkippableFact] + public async Task HttpSlow_TimeoutViaOnError_RollbackBoundary_LandsRolledBack_WithIncident() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL). This case also takes just over 2 seconds " + + "to run — til-http-slow's timeoutSeconds:2 racing MockLab's delayMs:5000 route."); + + var instanceId = await RunCaseAsync("case-http-slow"); + + var (state, status) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal("rolled-back", state); + Assert.NotEqual("F", status); + + var incident = BoundaryIncident(await GetIncidentItemsAsync(instanceId)); + Assert.Equal("Rollback", Text(incident, "boundaryAction")); + Assert.Equal("Task", Text(incident, "boundaryLevel")); + // See this class's remarks: a MATCHED incident's errorCode is BuildIncident's short form + // (NormalizedError.Code, Task:{TaskType}:{TaskKey} — no status here, and never an exception + // type), not the longer string an UNMATCHED failure would display. + Assert.Equal("Task:Http:til-http-slow", Text(incident, "errorCode")); + + // Same auto-resolve rule as Http500_NotifyBoundary above (FinalizeTransitionStep + // .ResolveIncidentOnSuccessfulErrorBoundaryTransition): Rollback-with-a-transition lands + // the same way Notify-with-a-transition does — the instance completes (Status 'C') and the + // incident this boundary raised is already resolved by the time it gets here. + var metadata = await GetIncidentMetadataAsync(instanceId); + Assert.NotNull(metadata); + Assert.False(Flag(metadata!.Value, "hasActiveIncident"), + "a Rollback boundary that routes to a transition resolves its own incident once that " + + "transition lands without faulting, exactly like Notify"); + AssertAbsent(metadata.Value, "active", "resolved incidents carry no active-incident link"); + + // A client HttpClient timeout never produced an HTTP response: no status code, no body. + var projection = await GetProjectionAsync(instanceId); + Assert.Equal("case-http-slow", NullableString(projection, "tilCase")); + Assert.Null(NullableLong(projection, "tilStatusCode")); + Assert.Equal(0, NullableLong(projection, "tilBodyLength") ?? 0); + Assert.False(BoolOrFalse(projection, "tilHasData")); + } + + [SkippableFact] + public async Task SoapFault_AbortBoundary_FaultsTheInstance_WithIncident() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-soap-fault"); + await WaitUntilFaultedAsync(instanceId); + + // Abort has no `transition` in til-soap-fault's boundary — the instance faults from the + // state it was already in (`ready`), it never reaches `landed`. + var (state, _) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal(ReadyState, state); + + var items = await GetIncidentItemsAsync(instanceId); + var incident = BoundaryIncident(items); + Assert.Equal("Abort", Text(incident, "boundaryAction")); + Assert.Equal("Task", Text(incident, "boundaryLevel")); + Assert.Equal(500, Number(incident, "statusCode")); + + var metadata = await GetIncidentMetadataAsync(instanceId); + Assert.NotNull(metadata); + Assert.True(Flag(metadata!.Value, "hasActiveIncident")); + } +} diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/FunctionResponseCacheTests.cs b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/FunctionResponseCacheTests.cs new file mode 100644 index 0000000..e1f16a2 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/FunctionResponseCacheTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.TaskInvocationLab; + +/// +/// The fourth path issue #1007 moved onto the routing seam, and the one no other scenario in this +/// repository exercises: the FUNCTION RESPONSE CACHE. FunctionAppService reads and writes it +/// through IStateStoreCacheGateway, which since #1007 goes out through +/// ITaskInvocationDispatcher — the same seam as the five wire task types, so it runs +/// in-process on Orchestration by default and falls back to the Execution service when +/// Workflow:TaskInvocation:Modes:statestore is set to Remote. +/// +/// Why a dedicated function exists for this. Before til-cached-echo, no component in +/// this repository configured function.cache at all, so this gateway — and the +/// Cache.Get/Cache.Set spans it emits under component type function-response — +/// was never reached end to end in any run, local or CI. +/// +/// +/// +/// Like , this file is meant to be run in BOTH routing modes +/// and to pass unchanged in each: nothing here branches on the active mode. The measured cost does +/// differ (a cache hit resolves in roughly 0.7 ms in-process against roughly 2.2 ms through the +/// Execution service), but cost is not what this file asserts — behaviour is. +/// +public sealed class FunctionResponseCacheTests : TaskInvocationLabTestBase +{ + private const string FunctionKey = "til-cached-echo"; + + public FunctionResponseCacheTests(VNextTestEnvironment environment) : base(environment) + { + } + + /// + /// A miss computes the response and a hit replays it verbatim. The proof is + /// computedAtUtc, which the mapping stamps with DateTime.UtcNow on every + /// execution: if the second call returns the SAME stamp, the function's task set did not run + /// and the value came out of the cache. Asserting on timing instead would be a flake. + /// + [SkippableFact] + public async Task CachedFunction_ServesTheSecondCallFromTheCache_WithoutReExecutingItsTasks() + { + Skip.If(!await IsMockLabUpAsync(), $"MockLab is not reachable at {MockLabBaseUrl()}."); + + // The cached response outlives a single test (ttlInSeconds = 60 on the component), so the + // first call here may well be a hit left by an earlier run. Take three readings: whatever + // the first one was, calls two and three must agree with each other. + var first = await ReadStampAsync(); + var second = await ReadStampAsync(); + var third = await ReadStampAsync(); + + Assert.False(string.IsNullOrWhiteSpace(second), "the cached function returned no computedAtUtc"); + Assert.Equal(second, third); + Assert.Equal(first, second); + } + + /// + /// The cache must not change what the caller sees. A cached response is the same + /// FunctionResponseOutput the miss produced — same wrapper key, same payload fields — + /// because it is deserialized back into that type rather than replayed as raw bytes. + /// + [SkippableFact] + public async Task CachedFunction_KeepsTheResponseShape_OnBothTheMissAndTheHit() + { + Skip.If(!await IsMockLabUpAsync(), $"MockLab is not reachable at {MockLabBaseUrl()}."); + + for (var call = 0; call < 2; call++) + { + using var response = await RawClient.GetAsync($"api/v1/core/functions/{FunctionKey}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var data = document.RootElement.GetProperty("tilCachedEcho").GetProperty("data"); + + Assert.Equal("til-cached-echo", data.GetProperty("source").GetString()); + Assert.False(string.IsNullOrWhiteSpace(data.GetProperty("computedAtUtc").GetString())); + } + } + + private async Task ReadStampAsync() + { + using var response = await RawClient.GetAsync($"api/v1/core/functions/{FunctionKey}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement + .GetProperty("tilCachedEcho") + .GetProperty("data") + .GetProperty("computedAtUtc") + .GetString(); + } +} diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/README.md b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/README.md new file mode 100644 index 0000000..d37ed15 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/README.md @@ -0,0 +1,199 @@ +# task-invocation-lab — Local/Remote task invocation routing, error boundary, sonuç şekli parity + +## Neyi denetliyor + +Üç iddia (vnext issue #1007 — Http, DaprService, Soap, StateStore ve CacheAside artık Orchestration +üzerinde in-process çalışıyor, `Workflow:TaskInvocation` routing config'i her tipi tek tek eskisi +gibi Execution servisine geri döndürebiliyor): + +1. **Beş task tipi de in-process (Local) yolda çalışıyor.** Http (tip 6), DaprService (tip 3), + Soap (tip 16), StateStore (tip 17, set/get round-trip) ve CacheAside (tip 18, miss-then-hit + round-trip) — hepsi `landed`'a ulaşıyor, incident yok, paylaşılan `TilResultProjection.csx` + mapping'inin yazdığı `til*` alanları bekleneni taşıyor. +2. **Error boundary, task'ın SONUCUNDAN doğru handler'ı seçiyor.** Üç kusur senaryosu: HTTP 500 → + task seviyesi Notify (`notified`), MockLab'in 5 saniye geciken route'una karşı 2 saniyelik + client timeout → task seviyesi Rollback (`rolled-back`), SOAP fault → task seviyesi Abort + (instance fault olur). Üçü de aynı task referansının `errorBoundary.onError` bloğunda — + `boundaryLevel` her zaman `Task`. +3. **`TaskInvocationResult` şekli routing moduna göre değişmiyor.** Aynı testler, Orchestration + host'u önce şu anki varsayılan (beş tip de Local) ile, sonra hepsi Remote'a zorlanmış halde + koşturulduğunda DEĞİŞMEDEN geçmeli — bkz. aşağıdaki "Nasıl koşulur (iki mod)". +4. **Fonksiyon yanıt cache'i (`IStateStoreCacheGateway`) de aynı seam üzerinde.** `til-cached-echo` + fonksiyonu `function.cache` yapılandıran TEK bileşen: bu repoda başka hiçbir senaryo bu yolu + kullanmıyordu, yani gateway ve onun `function-response` bileşen tipiyle yaydığı + `Cache.Get`/`Cache.Set` span'leri hiçbir koşuda uçtan uca çalışmıyordu. + +## Neden var + +vnext `1007-…` dalı beş task tipini ayrı bir Execution servisinden Orchestration host'una taşıdı, +her tipi `Workflow:TaskInvocation:Modes` ile tek tek Remote'a döndürebilen bir routing katmanı +arkasında (`docs/runtime/task-invocation-routing.md`). Bu değişiklikten önce vnext-example'da +DaprService, Soap ve StateStore/CacheAside tiplerinin **hiç** bileşeni yoktu — üçü de bu senaryoyla +ilk kez örnekleniyor. `tilTaskType` alanı ayrıca bu dalda routing'e bağlı olarak iki kere yanlış +damgalanmış bir alan (build raporuna bakın) ve `InstanceTasks` günlüğüne serileştiriliyor, bu yüzden +`ResultModelParityTests` özellikle onu hedefliyor. + +## Akış şeması + +Tek workflow (`task-invocation-lab`, tip `F`), hub state `ready` üzerinde bir case başına bir +transition: + +``` +task-invocation-lab + start → ready (hub) + case-http-ok ──────────► landed (Http 200) + case-http-500 ─────────► notified (Http 500 → Task Notify) + case-http-slow ────────► rolled-back (client timeout → Task Rollback, onError ile — onTimeout DEĞİL) + case-dapr-ok ───────────► landed (DaprService → mocklab app-id) + case-soap-ok ───────────► landed (Soap 1.1 200) + case-soap-fault ────────► F (Abort) (SOAP fault üzerinden HTTP 500) + case-statestore-set ────► landed (Dapr state store'a yaz) + case-statestore-get ────► landed (aynı statik anahtarı oku) + case-cacheaside ────────► landed (til-cache-source üzerinden read-through; iki instance = miss + hit) + cancel-task-invocation-lab ► til-cancelled (şekil paraleliği için — error-boundary-lab'dan) +``` + +Workflow'un dışında, domain kapsamlı tek bir fonksiyon: + +``` +GET /api/v1/core/functions/til-cached-echo (scope D) + cache: { key "til:fncache:echo", ttlInSeconds 60 } + MISS → til-http-ok çalışır, yanıt cache'e yazılır + HIT → task'lar HİÇ çalışmaz, cache'teki yanıt aynen döner +``` + +**`onTimeout` değil `onError`, ve `errorCodes` değil `errorTypes` kullanıldı — ikisi de canlı +runtime'da (postgres `InstanceIncidents` satırları okunarak) doğrulandı, tahminle DEĞİL.** +`errorBoundary.onTimeout` şema-geçerli, publish oluyor, ama `CompiledBoundary.Compile` onu HİÇ +okumuyor — açık şekilde yazılmış bir timeout handler'ı sessizce hiçbir şey yapmıyor. Bir HTTP +client timeout'u boundary'ye ayrı bir "timeout" sinyali olarak hiç ulaşmıyor: `StatusCode: null`'lu +sıradan bir `TaskInvocationResult.Failure` ve `Metadata["ExceptionType"] = "TaskCanceledException"` +olarak geliyor — timeout-şekilli bir kod DEĞİL, çıplak bir .NET tip adı. + +Daha da önemlisi: `errorCodes` bu başarısızlıkla HİÇBİR ZAMAN eşleşemez — ne kısa form +(`Task:Http:til-http-slow`), ne tam kompozit form (`Task:Http:til-http-slow:TaskCanceledException`), +ne de çıplak `TaskCanceledException` metni. Retry kuralı olmadığında eşleşme her zaman +`TaskExecutionEngine.HandlePostRetryFailureAsync`'in `ResolveExcluding` çağrısından geçer, ve o yol +`CompiledBoundaryChain.FindMatchExcluding(NormalizedError, …)` üzerinden `error.OriginalCode` +(bu hata için her zaman null — `HttpTaskInvocation` hiç `Metadata["ErrorCode"]` yazmıyor) ve +`error.ExceptionType` ("TaskCanceledException") ile eşleştirir; `error.Code` bu yolda HİÇ +kullanılmaz. `errorCodes` sadece `OriginalCode`/`StatusCode`'a bakar — ikisi de null olduğunda +(client-side timeout) `errorCodes` içine ne yazılırsa yazılsın (`"*"` hariç) eşleşme imkansızdır. +Tek çalışan alan `errorTypes` — `error.ExceptionType`'a bakan `MatchesExceptionType`. `til-http-slow` +'un boundary'si bu yüzden `errorTypes: ["TaskCanceledException"]` olarak yazıldı; +`ErrorBoundaryInteractionTests` tam olarak bunu ve sonucu (`BoundaryAction: Rollback`, +`rolled-back`/`Completed`) doğruluyor. + +**Sonuç: bugün bir domain yazarı için HTTP task timeout'unu yakalamanın keşfedilebilir hiçbir yolu +yok.** `onTimeout` etkisiz, `errorCodes` bu hata için yapısal olarak asla eşleşemez (statusCode ve +OriginalCode ikisi de null), ve eşleşen tek şey — `errorTypes` altında çıplak bir .NET istisna tipi +adı — şemada `errorCodes`'dan ayırt edilemiyor, yani gayet makul görünen bir +`errorCodes: ["TaskCanceledException"]` sessizce hiçbir şey yapmıyor. `errorTypes: ["*"]` tek genel +alternatif ama SADECE timeout'u değil, istisna kaynaklı her başarısızlığı yakalıyor. + +**StateStore ve CacheAside statik anahtar kullanıyor** (`til:statestore:roundtrip`, +`til:cacheaside:roundtrip`) — instance-scoped değil, paylaşılan Dapr state store'un kendisi. `set` +her zaman aynı literal değeri yazdığı için sıra bağımsızdır; CacheAside'ın miss-then-hit'i DEĞİL — +60 saniyelik TTL içinde aynı anahtara dokunan BAŞKA bir case/test de "hit" üretebilir. Bu yüzden +`TaskTypeInvocationTests.CacheAsideRoundTrip_MissesThenHits` bunu tek yerde ölçer, +`ResultModelParityTests` ise `CacheHit` boole değerini hiç iddia etmez (bkz. o dosyanın XML açıklaması). + +## Nasıl koşulur (iki mod) + +Runtime **lokal derlenmiş** olmalı (`1007-…` dalı henüz release edilmedi). + +```bash +# 1) altyapı +cd ../vnext/etc/docker && ./run-docker.sh + +# 2) dört host, her biri ayrı terminalde — MOD 1 (varsayılan: beş tip de Local) +dotnet run --project orchestration/BBT.Workflow.Orchestration.HttpApi.Host --launch-profile http +dotnet run --project execution/BBT.Workflow.Execution.HttpApi.Host --launch-profile http +dotnet run --project workers/BBT.Workflow.Workers.Inbox --launch-profile http +dotnet run --project workers/BBT.Workflow.Workers.Outbox --launch-profile http + +# 3) MockLab (bu repo) — task-invocation-lab-collection.json seed'i yalnız koleksiyon YENİYSE içeri alınır +cd ../vnext-example && docker compose up -d # gerekirse: docker compose down -v && docker compose up -d + +# 4) testler — MOD 1 (Local, varsayılan appsettings.json) +cd tests/Core.IntegrationTests +dotnet test --settings test.runsettings --filter "FullyQualifiedName~TaskInvocationLab" +``` + +**MOD 2 — beş tipi de Remote'a zorlayıp AYNI testleri tekrar koşturun** (yalnız +`ResultModelParityTests`'in anlamlı olması için değil, tüm dosyanın iki modda da değişmeden +geçtiğini görmek için): + +```bash +# Orchestration host'u DURDURUN, bu env değişkenleriyle YENİDEN başlatın +# (TaskInvocationOptions tek seferlik bind edilir — restart şart, appsettings.json değişikliği YETMEZ): +Workflow__TaskInvocation__Modes__http=Remote \ +Workflow__TaskInvocation__Modes__daprservice=Remote \ +Workflow__TaskInvocation__Modes__soap=Remote \ +Workflow__TaskInvocation__Modes__statestore=Remote \ +Workflow__TaskInvocation__Modes__cacheaside=Remote \ +dotnet run --project orchestration/BBT.Workflow.Orchestration.HttpApi.Host --launch-profile http + +# Execution servisi zaten ayakta olmalı (Remote yolun gideceği yer budur) + +cd tests/Core.IntegrationTests +dotnet test --settings test.runsettings --filter "FullyQualifiedName~TaskInvocationLab" +``` + +| Değişken | Anlamı | +| --- | --- | +| `VNEXT_BASE_URL` | Konteyner başlatmayı atlar, verilen orchestrator'a bağlanır. | +| `MOCKLAB_BASE_URL` | MockLab admin API'si; varsayılan `http://localhost:3001`. Ulaşılamazsa MockLab'a bağlı testler (Http/DaprService/Soap/CacheAside case'leri) **skip** olur; iki StateStore case'i MockLab'a hiç bağlı değildir. | + +## Beklenen sonuç + +| Sınıf | Doğruladığı | +| --- | --- | +| `TaskTypeInvocationTests` | Beş tipin başarı yolu; `landed`, incident yok, projeksiyon alanları (`tilCase`/`tilStatusCode`/`tilHasData`/`tilData`); StateStore set→get round-trip; CacheAside miss→hit round-trip. | +| `ErrorBoundaryInteractionTests` | Task seviyesi Notify/Rollback/Abort'un doğru state'e/incident'e/fault'a götürdüğü; timeout case'inin `onError`+`errorTypes: ["TaskCanceledException"]` üzerinden çalıştığı (ne `onTimeout`, ne `errorCodes`); Notify VE Rollback'in ikisinin de kendi incident'ını landing transition başarıyla tamamlanınca otomatik resolve ettiği; timeout'ta `tilStatusCode`'un null, `tilBodyLength`'in 0 olduğu. | +| `FunctionResponseCacheTests` | Fonksiyon yanıt cache'inin miss→hit davranışı: ikinci çağrının `computedAtUtc` damgası birincininkiyle AYNI (yani task seti hiç çalışmadı) ve cache'ten dönen yanıt aynı `FunctionResponseOutput` şeklini koruyor. İki modda da değişmeden geçer. | +| `ResultModelParityTests` | **İki modda da DEĞİŞMEDEN geçmesi gereken** projeksiyon şekli: `tilTaskType` (case-insensitive — bkz. dosyanın XML açıklaması), `tilStatusCode`, metadata anahtar kümesi. Bu dosyanın değeri TEK bir koşudan değil, yukarıdaki iki komutun İKİSİNDEN de gelir. | + +## Ölçülen / bilinen sınırlar + +1. **`tilTaskType` case-insensitive doğrulanıyor.** Runtime kaynağını okuyarak (çalıştırmadan) + bulunan gerçek: `TaskExecutorBase.CreateSuccessResponse`/`CreateErrorResponse` `TaskType.ToString()` + (PascalCase enum adı, örn. `"Http"`) damgalıyor; `CacheAsideTaskExecutor` ve + `HttpTaskInvocation`/`DaprServiceInvocation`/`StateStoreInvocation`'ın kullandığı + `TaskInvocationResult` fabrikaları ise `BBT.Workflow.Execution.TaskTypes`'ın küçük harfli wire + sabitini taşıyor (örn. `"cacheaside"`). Her iki kod yolu da Local/Remote routing'den BAĞIMSIZ, + koşulsuz paylaşılıyor — yani casing farkı tek başına bir Local/Remote sapması KANITLAMAZ. Bu + testler bu yüzden case-insensitive kimliği pinliyor (doğru tip, hiç boş değil, moddan bağımsız); + kesin casing'in kendisi bu senaryonun iddiası değil. +2. **`retryCount` gibi bir alan burada yok** — bu senaryonun hiçbir boundary'si retry yapmıyor + (Notify/Rollback/Abort hepsi tek denemelik). `ErrorBoundaryLab.RetryPolicyTests`'in ölçtüğü + `retryCount`'un her zaman 0 olması bulgusu burada tekrarlanmıyor çünkü ölçülecek bir retry yok. +3. **CacheAside miss/hit sırası paylaşılan statik anahtara bağlı.** `til:cacheaside:roundtrip`'in + 60 saniyelik TTL'i, bu paketteki BAŞKA bir case (veya aynı paketin `ResultModelParityTests`'i) + yakın zamanda aynı anahtara dokunduysa "ilk çağrı miss" varsayımını bozabilir — + `TaskTypeInvocationTests` bunu tek yerde iddia eder ve XML açıklamasında bu riski adlandırır. +4. **`til-cache-source`'un URL'i literal `http://localhost:3001`.** Component build raporunun + bulduğu gibi, `CacheAsideTaskExecutor.InvokeAsync` kaynak task'ın kendi `InputHandler`'ını hiç + çalıştırmıyor, bu yüzden `API_BASEURL` placeholder'ı bu task için çözülemiyor. `MOCKLAB_BASE_URL` + diğer case'leri yönlendirse bile bu task'ı yönlendirmez — varsayılan MockLab portundan farklı bir + ortamda bu test kırılır. +5. **DaprService case'i (`til-dapr-ok`) MockLab'in ayakta olmasının ÖTESİNDE bir önkoşula sahip**: + lokal Dapr sidecar'ının `mocklab` app-id'sini MockLab'e yönlendiren bir bileşene ihtiyacı var. + `IsMockLabUpAsync()` yalnız MockLab'in admin API'sini kontrol eder, bu Dapr yönlendirmesini + DOĞRULAMAZ — sidecar yanlış yapılandırılmışsa test MockLab ayaktayken bile başarısız olabilir ve + hata mesajı bunu ayırt etmez. +6. **`til-cached-echo` kendi `InputHandler`'ında `API_BASEURL`'i ÇÖZMEK ZORUNDA.** Bir fonksiyon + kendi mapping'ini verdiğinde task'ın kendi mapping'inin yerine geçer — task tanımındaki + placeholder'ı çözen de odur. İlk yazımda bu atlandı ve çağrı 500 ile öldü ("request URI must be + absolute"); mapping'in `InputHandler`'ı artık diğer örnek fonksiyonlarla aynı + `GetConfigValue("Example:ApiBaseUrl", …)` satırını taşıyor. +7. **Cache TTL'i 60 saniye, bu yüzden `FunctionResponseCacheTests` ilk okumayı MISS varsaymaz.** + Üç okuma alır ve ikisinin birbiriyle uyuşmasını arar; zamanlamaya göre değil, damganın + donmasına göre karar verir. + +## Ölçüm + +Bu senaryonun trafiğiyle alınan Local/Remote span ölçümleri ve trace bütünlüğü denetimi vnext +reposunda: `docs/runtime/evidence/2026-09-19-task-invocation-routing/README.md`. Özet: hop tip +başına `Task.Invoke` p50'sine 1.5–2.4 ms ekliyor, fonksiyon cache'i hit'te 0.68 ms (Local) ve +2.19 ms (Remote), ve iki modda da eksik span ailesi ya da yetim span YOK. diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ResultModelParityTests.cs b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ResultModelParityTests.cs new file mode 100644 index 0000000..a936838 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/ResultModelParityTests.cs @@ -0,0 +1,182 @@ +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.TaskInvocationLab; + +/// +/// The third claim this scenario exists for (vnext issue #1007): the persisted +/// TaskInvocationResult / StandardTaskResponse shape a workflow's mapping sees is the +/// SAME regardless of whether Workflow:TaskInvocation:Modes:{wireType} resolves a task to +/// Local (in-process on Orchestration, the shipped default for these five types) or +/// Remote (shipped to the Execution service, exactly as every task ran before issue #1007). +/// +/// +/// +/// This file's value comes from being run TWICE against the same components, not from either +/// run alone. Run it once as-is (the shipped default routes all five types Local), then again +/// with every type forced back to Remote: +/// +/// +/// # Run 1 — shipped default (Local for http/daprservice/soap/statestore/cacheaside): +/// dotnet test tests/Core.IntegrationTests --filter "FullyQualifiedName~TaskInvocationLab.ResultModelParityTests" +/// +/// # Run 2 — force every type back to Remote. Set these BEFORE starting the Orchestration host +/// # (TaskInvocationOptions binds once at startup — see docs/runtime/task-invocation-routing.md +/// # "Reverting a type to Remote" in the vnext repo), then re-run the SAME filter: +/// Workflow__TaskInvocation__Modes__http=Remote \ +/// Workflow__TaskInvocation__Modes__daprservice=Remote \ +/// Workflow__TaskInvocation__Modes__soap=Remote \ +/// Workflow__TaskInvocation__Modes__statestore=Remote \ +/// Workflow__TaskInvocation__Modes__cacheaside=Remote \ +/// dotnet run --project orchestration/BBT.Workflow.Orchestration.HttpApi.Host --launch-profile http +/// # (the Execution service must also be running for Remote dispatch to have anywhere to go) +/// dotnet test tests/Core.IntegrationTests --filter "FullyQualifiedName~TaskInvocationLab.ResultModelParityTests" +/// +/// +/// Both runs are expected to pass UNCHANGED — no test here branches on which mode is active, by +/// design (the whole point is that the caller-visible shape must not need to know). +/// +/// +/// Why tilTaskType is asserted case-insensitively. This field is the parity trap the +/// scenario was built to catch — the component build report notes it was mis-stamped twice on +/// this branch depending on routing, and it is what gets serialized into the InstanceTasks +/// journal. Reading the runtime source directly (not by running it — this task forbids that) +/// shows the casing itself is not a single, uniform contract today: +/// TaskExecutorBase.CreateSuccessResponse/CreateErrorResponse stamp +/// TaskType.ToString() off the local TaskType enum (PascalCase, e.g. "Http"), +/// while CacheAsideTaskExecutor and the TaskInvocationResult factories used by +/// HttpTaskInvocation/DaprServiceInvocation/StateStoreInvocation carry the +/// lowercase wire constant from BBT.Workflow.Execution.TaskTypes (e.g. "cacheaside"). +/// Both of those code paths are shared, unconditionally, by the Local and Remote dispatch of the +/// SAME wire type — so a casing choice does not by itself prove a Local/Remote divergence, and +/// asserting one exact casing here would fail for a reason unrelated to the routing-parity claim +/// this class exists to check. What this test DOES pin, in both modes: the identifier names the +/// correct task type, is never empty, and does not depend on which path resolved the call. +/// +/// +public class ResultModelParityTests : TaskInvocationLabTestBase +{ + public ResultModelParityTests(VNextTestEnvironment environment) : base(environment) { } + + [SkippableFact] + public async Task Http_ProjectionShape_IsStableAcrossRoutingModes() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-http-ok"); + var projection = await GetProjectionAsync(instanceId); + + Assert.Equal("http", NullableString(projection, "tilTaskType"), ignoreCase: true); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + + // HttpTaskInvocation (shared by the Local and Remote path alike) always attaches exactly + // these three metadata keys on success — verified by reading the invocation source, not by + // running it. + AssertMetadataKeySet(projection, "Url", "Method", "ReasonPhrase"); + } + + [SkippableFact] + public async Task DaprService_ProjectionShape_IsStableAcrossRoutingModes() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL). til-dapr-ok also needs the local Dapr " + + "sidecar routing the 'mocklab' app-id to it."); + + var instanceId = await RunCaseAsync("case-dapr-ok"); + var projection = await GetProjectionAsync(instanceId); + + // See TaskInvocationLabTestBase.IsDaprServiceUnreachable's remarks: this local + // Orchestration sidecar cannot resolve MockLab's 'mocklab' Dapr app-id via mDNS at all + // (verified by curling the sidecar's own invoke endpoint directly), independent of routing + // mode — both Local and Remote dispatch go through the same sidecar. + Skip.If(IsDaprServiceUnreachable(projection), + "Dapr service invocation from the Orchestration sidecar to MockLab's 'mocklab' app-id " + + "is not reachable in this local setup (sidecar answers 500 ERR_DIRECT_INVOKE — " + + "\"couldn't find service: mocklab\"). Needs the sidecars' mDNS discovery fixed at the " + + "infra level, not a component change."); + + Assert.Equal("daprservice", NullableString(projection, "tilTaskType"), ignoreCase: true); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + + // DaprServiceInvocation attaches no metadata dictionary today, in either routing mode. + Assert.Equal(0, ArrayLength(projection, "tilMetadataKeys")); + } + + [SkippableFact] + public async Task Soap_ProjectionShape_IsStableAcrossRoutingModes() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-soap-ok"); + var projection = await GetProjectionAsync(instanceId); + + Assert.Equal("soap", NullableString(projection, "tilTaskType"), ignoreCase: true); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + + // SoapInvocation (shared by the Local and Remote path alike) always attaches exactly these + // five metadata keys on a non-fault response — verified by reading the invocation source + // (src/BBT.Workflow.Execution.Core/Invocation/SoapInvocation.cs in the vnext repo), not by + // running it. + AssertMetadataKeySet(projection, "Url", "Method", "ReasonPhrase", "SoapVersion", "IsSoapFault"); + } + + /// + /// Uses case-statestore-set only (not the round-trip get) — set always + /// writes the same literal value and is therefore order-independent, unlike CacheAside below. + /// + [Fact] + public async Task StateStore_ProjectionShape_IsStableAcrossRoutingModes() + { + var instanceId = await RunCaseAsync("case-statestore-set"); + var projection = await GetProjectionAsync(instanceId); + + Assert.Equal("statestore", NullableString(projection, "tilTaskType"), ignoreCase: true); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + + // StateStoreInvocation.BaseMetadata (shared by Local and Remote alike) always attaches + // StoreName + Command + Key, and SetAsync adds Saved. + AssertMetadataKeySet(projection, "StoreName", "Command", "Key", "Saved"); + } + + /// + /// Deliberately does NOT assert the CacheHit boolean — see + /// 's remarks on why that value depends on which other + /// case in this run last touched the same static cache key, not on the routing mode. What + /// stays true either way (hit or miss) is the metadata KEY SET and the task type/status code, + /// because CacheAsideInvocation.BuildMetadata always attaches the same five keys and the + /// underlying source dispatch always defaults to statusCode: 200 on success regardless + /// of which branch (cache read vs. source dispatch) produced the result. + /// + [SkippableFact] + public async Task CacheAside_ProjectionShape_IsStableAcrossRoutingModes() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL). til-cache-source is a literal " + + "http://localhost:3001 URL (see the component build report) so MOCKLAB_BASE_URL cannot " + + "redirect it."); + + var instanceId = await RunCaseAsync("case-cacheaside"); + var projection = await GetProjectionAsync(instanceId); + + Assert.Equal("cacheaside", NullableString(projection, "tilTaskType"), ignoreCase: true); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + Assert.True(BoolOrFalse(projection, "tilHasData")); + Assert.Equal("til-cache-source-value", projection.GetProperty("tilData").GetProperty("value").GetString()); + + AssertMetadataKeySet(projection, "StoreName", "Key", "CacheHit", "Refreshed", "ETag"); + } + + private static void AssertMetadataKeySet(System.Text.Json.JsonElement projection, params string[] expectedKeys) + { + var actual = MetadataKeySet(projection); + var expected = expectedKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.True(actual.SetEquals(expected), + $"expected metadata keys {{{string.Join(",", expected)}}}, got {{{string.Join(",", actual)}}}"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskInvocationLabTestBase.cs b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskInvocationLabTestBase.cs new file mode 100644 index 0000000..1e80943 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskInvocationLabTestBase.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; +using Core.IntegrationTests.Tests.ErrorBoundaryLab; + +namespace Core.IntegrationTests.Tests.TaskInvocationLab; + +/// +/// Plumbing for the task-invocation-lab scenario (vnext issue #1007: Http, DaprService, Soap, +/// StateStore and CacheAside all run in-process on Orchestration now, behind +/// Workflow:TaskInvocation routing that can flip each type back to the Execution service +/// one key at a time — see docs/runtime/task-invocation-routing.md in the vnext repo). +/// +/// Mirrors Tests/ErrorBoundaryLab/ErrorBoundaryLabTestBase: GET .../instances/{id}/incidents +/// has no SDK method, so the incident reads here are raw HTTP, same as that class. This class +/// reuses rather than duplicating it — the lab's +/// only stateful MockLab need is "is it up", and that lab's client already answers exactly that +/// (this scenario has no sequential mock, so ResetSequenceAsync is simply unused). +/// +/// +/// Every case here starts a fresh instance on ready and fires exactly one case transition +/// (case-http-ok, case-dapr-ok, …) — there is no multi-step flow to drive, unlike +/// error-boundary-lab's shouldFail parked-instance pattern. +/// +/// +public abstract class TaskInvocationLabTestBase : WorkflowTestBase +{ + protected const string Workflow = "task-invocation-lab"; + protected const string ReadyState = "ready"; + + /// An orchestrator client this class owns, for the incident endpoints the SDK has no method for. + protected HttpClient RawClient { get; } + + protected TaskInvocationLabTestBase(VNextTestEnvironment environment) : base(environment) + { + RawClient = new HttpClient + { + BaseAddress = new Uri(environment.OrchestratorBaseUrl.TrimEnd('/') + "/") + }; + } + + // ── running a case ─────────────────────────────────────────────────────── + + /// Starts a fresh instance and waits until it is parked on ready. + protected async Task StartInstanceAsync() + { + var instanceId = await StartAsync(Workflow, new { }); + await WaitUntilSettledAsync(Workflow, instanceId); + return instanceId; + } + + /// + /// Runs a whole case: fresh instance on ready, one case transition, settled. Tolerates + /// the instance faulting afterwards — the SOAP-fault case's task-level Abort boundary faults it + /// by design, and only the initial accept has to succeed here, exactly like + /// ErrorBoundaryLabTestBase.RunCaseAsync. + /// + protected async Task RunCaseAsync(string caseKey) + { + var instanceId = await StartInstanceAsync(); + await RunAcceptedAsync(Workflow, instanceId, caseKey); + return instanceId; + } + + /// + /// The precondition for every case whose task calls MockLab (Http, DaprService, Soap, and + /// CacheAside's source task) — the two StateStore cases talk to the Dapr state component + /// directly and need no MockLab guard. Call from a [SkippableFact]: + /// Skip.If(!await IsMockLabUpAsync(), "…") + /// + protected static async Task IsMockLabUpAsync() + { + using var mocklab = new MockLabAdminClient(); + return await mocklab.IsUpAsync(); + } + + /// The MockLab base URL, for the skip message. + protected static string MockLabBaseUrl() + { + using var mocklab = new MockLabAdminClient(); + return mocklab.BaseUrl; + } + + /// + /// True when case-dapr-ok's projection shows the Orchestration sidecar could not reach + /// MockLab's Dapr app-id AT ALL — a transport/name-resolution failure — rather than a genuine + /// business response from the callee. Confirmed by curling the Orchestration sidecar's own + /// invoke endpoint directly (POST /v1.0/invoke/mocklab/method/api/til/ok against its + /// dapr-http-port): it answers 500 {"errorCode":"ERR_DIRECT_INVOKE","message":"failed + /// to invoke, id: mocklab, err: couldn't find service: mocklab"} even though + /// til-dapr-ok's appId/methodName match the working + /// core/Tasks/money-transfer/get-accounts-dapr.json pattern exactly and both sidecars + /// report Initialized name resolution to mdns on the same Docker network — i.e. this is + /// the local mDNS-across-sidecars gap the vnext repo's cross-domain-lab notes already document, + /// not a task/component misconfiguration. + /// (in the vnext repo) never throws on this: the sidecar's + /// own error response comes back as an ordinary (non-2xx) HTTP response, so it lands as + /// tilStatusCode: 500 with the sidecar's error JSON parsed into tilData — a + /// business-shaped path, not a fault, which is why the instance still lands cleanly instead of + /// raising an incident. + /// + protected static bool IsDaprServiceUnreachable(System.Text.Json.JsonElement projection) => + NullableLong(projection, "tilStatusCode") == 500 && + projection.TryGetProperty("tilData", out var data) && + data.ValueKind == JsonValueKind.Object && + Text(data, "errorCode") == "ERR_DIRECT_INVOKE"; + + // ── incident surfaces (subset of ErrorBoundaryLabTestBase — see its remarks for why raw HTTP) ── + + /// The incident items of an instance, newest first. Fails the test on a non-200. + protected async Task> GetIncidentItemsAsync(string instanceId, int pageSize = 20) + { + var url = $"api/v1/core/workflows/{Workflow}/instances/{instanceId}/incidents?page=1&pageSize={pageSize}"; + var (status, body) = await SendRawAsync(HttpMethod.Get, url, headers: Headers()); + Assert.True(status == HttpStatusCode.OK, $"incident history refused with {(int)status}: {body}"); + + return Parse(body).GetProperty("items").EnumerateArray().ToList(); + } + + /// The newest incident carrying boundary metadata — see ErrorBoundaryLabTestBase.BoundaryIncident. + protected static JsonElement BoundaryIncident(List items) + { + var match = items.FirstOrDefault(i => i.TryGetProperty("boundaryAction", out var action) && + action.ValueKind == JsonValueKind.String); + + Assert.True(match.ValueKind == JsonValueKind.Object, + "no incident carried boundaryAction — the boundary never resolved an action. " + + $"Incidents: {string.Join(" | ", items.Select(i => Text(i, "errorCode")))}"); + + return match; + } + + /// metadata.incident of GET .../instances/{id}, or null when absent. + protected async Task GetIncidentMetadataAsync(string instanceId) + { + var response = await Api.GetInstanceAsync(Workflow, instanceId, Headers()); + var metadata = response.Body.GetProperty("metadata"); + + return metadata.TryGetProperty("incident", out var incident) && incident.ValueKind != JsonValueKind.Null + ? incident + : null; + } + + // ── waiting ────────────────────────────────────────────────────────────── + + /// Waits for the instance to fault — the inverse of a settle-and-succeed wait. + protected Task WaitUntilFaultedAsync(string instanceId, TimeSpan? timeout = null) => + WaitUntilAsync( + async () => + { + var (_, status) = await GetInstanceStateAsync(Workflow, instanceId); + if (status == "F") return true; + + Assert.True(status is not ("C" or "P"), + $"instance settled '{status}' instead of faulting — " + await DescribeAsync(Workflow, instanceId)); + return false; + }, + $"{Workflow}/{instanceId} never faulted", + timeout); + + // ── projection / assertion helpers ────────────────────────────────────── + + /// Reads the til* projection TilResultProjection.csx writes into instance data. + protected Task GetProjectionAsync(string instanceId) => GetAttributesAsync(Workflow, instanceId); + + protected static string? NullableString(JsonElement attributes, string property) => + attributes.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + protected static long? NullableLong(JsonElement attributes, string property) => + attributes.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt64() + : null; + + protected static bool BoolOrFalse(JsonElement attributes, string property) => + attributes.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; + + protected static int ArrayLength(JsonElement attributes, string property) => + attributes.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Array + ? value.GetArrayLength() + : 0; + + /// The keys of tilMetadataKeys, for a set comparison against the expected shape. + protected static HashSet MetadataKeySet(JsonElement attributes) => + attributes.TryGetProperty("tilMetadataKeys", out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray().Select(e => e.GetString() ?? string.Empty) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + protected static string Text(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "-" + : "-"; + + protected static int? Number(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.TryGetInt32(out var number) ? number : null; + + protected static bool Flag(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; + + /// Asserts a property is absent — the runtime omits nulls, so absence IS the null. + protected static void AssertAbsent(JsonElement element, string property, string because) => + Assert.True(!element.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null, + $"expected '{property}' to be absent: {because}."); + + private static JsonElement Parse(string body) => + string.IsNullOrWhiteSpace(body) ? default : JsonDocument.Parse(body).RootElement.Clone(); +} diff --git a/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskTypeInvocationTests.cs b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskTypeInvocationTests.cs new file mode 100644 index 0000000..281719e --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TaskInvocationLab/TaskTypeInvocationTests.cs @@ -0,0 +1,206 @@ +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.TaskInvocationLab; + +/// +/// The success path of every one of the five task types moved onto Orchestration's in-process +/// invocation path by vnext issue #1007 — Http (type 6), DaprService (type 3), Soap (type 16), +/// StateStore (type 17) and CacheAside (type 18). Each case asserts the instance lands where the +/// workflow declares (landed), raises no incident, and that the shared +/// TilResultProjection.csx mapping wrote the fields its OutputHandler promises. +/// +/// +/// This class exists to prove the five types work at all on the local path — exact wire-shape +/// parity between Local and Remote routing (the field that matters most for that comparison, +/// tilTaskType) is 's job, not this one's. +/// +/// The StateStore and CacheAside cases are round-trips against a Dapr state store key that is +/// STATIC across every instance and every run (til:statestore:roundtrip, +/// til:cacheaside:roundtrip — see the component build report's deviation notes on why they +/// could not be made per-instance). The StateStore round-trip is safe from cross-test +/// interference because til-statestore-set always writes the same literal value — running +/// it twice or interleaved with another test changes nothing observable. The CacheAside +/// round-trip is NOT safe the same way: its whole point is observing a miss-then-hit transition, +/// and the cache-aside key's 60s TTL means a hit here can be produced by an ENTIRELY different +/// test (in this class or ) that touched the same key within +/// the last 60 seconds. This is why deliberately avoids +/// asserting the hit/miss boolean itself and only this class does. +/// +/// +public class TaskTypeInvocationTests : TaskInvocationLabTestBase +{ + public TaskTypeInvocationTests(VNextTestEnvironment environment) : base(environment) { } + + [SkippableFact] + public async Task HttpOk_Lands_NoIncident_ProjectsTheResult() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-http-ok"); + + var (state, status) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal("landed", state); + Assert.NotEqual("F", status); + Assert.Empty(await GetIncidentItemsAsync(instanceId)); + + var projection = await GetProjectionAsync(instanceId); + Assert.Equal("case-http-ok", NullableString(projection, "tilCase")); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + Assert.True(BoolOrFalse(projection, "tilHasData"), "til-http-ok's 200 response should parse as data"); + Assert.True((NullableLong(projection, "tilBodyLength") ?? 0) > 0, + "a 200 JSON body should have a non-zero raw length"); + } + + [SkippableFact] + public async Task DaprServiceOk_Lands_NoIncident_ProjectsTheResult() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL). til-dapr-ok also needs the local Dapr " + + "sidecar routing the 'mocklab' app-id to it."); + + var instanceId = await RunCaseAsync("case-dapr-ok"); + + var (state, status) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal("landed", state); + Assert.NotEqual("F", status); + Assert.Empty(await GetIncidentItemsAsync(instanceId)); + + var projection = await GetProjectionAsync(instanceId); + + // See IsDaprServiceUnreachable's remarks: this local Orchestration sidecar cannot resolve + // MockLab's 'mocklab' Dapr app-id via mDNS at all (verified by curling the sidecar's own + // invoke endpoint directly), so til-dapr-ok's business outcome is the sidecar's own + // ERR_DIRECT_INVOKE response rather than anything til-dapr-ok's config controls. + Skip.If(IsDaprServiceUnreachable(projection), + "Dapr service invocation from the Orchestration sidecar to MockLab's 'mocklab' app-id " + + "is not reachable in this local setup (sidecar answers 500 ERR_DIRECT_INVOKE — " + + "\"couldn't find service: mocklab\" — even though appId/methodName match the working " + + "core/Tasks/money-transfer/get-accounts-dapr.json pattern; both sidecars report mDNS " + + "name resolution initialized on the same Docker network). Needs the sidecars' mDNS " + + "discovery fixed at the infra level, not a component change."); + + Assert.Equal("case-dapr-ok", NullableString(projection, "tilCase")); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + Assert.True(BoolOrFalse(projection, "tilHasData"), + "til-dapr-ok's Dapr-invoked 200 response should parse as data, same body as til-http-ok"); + } + + [SkippableFact] + public async Task SoapOk_Lands_NoIncident_ProjectsTheResult() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL)."); + + var instanceId = await RunCaseAsync("case-soap-ok"); + + var (state, status) = await GetInstanceStateAsync(Workflow, instanceId); + Assert.Equal("landed", state); + Assert.NotEqual("F", status); + Assert.Empty(await GetIncidentItemsAsync(instanceId)); + + var projection = await GetProjectionAsync(instanceId); + Assert.Equal("case-soap-ok", NullableString(projection, "tilCase")); + Assert.Equal(200, NullableLong(projection, "tilStatusCode")); + Assert.True(BoolOrFalse(projection, "tilHasData"), + "the SOAP 1.1 success envelope should parse into 'data'"); + + // SoapInvocation.SendAsync always attaches these five keys on a non-fault response (see + // src/BBT.Workflow.Execution.Core/Invocation/SoapInvocation.cs in the vnext repo) — the + // extraction from the pre-#1007 SoapTaskInvoker preserved the metadata dictionary exactly, + // SoapFaultCode/SoapFaultString included for the fault path. It is never empty. + var expectedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { "Url", "Method", "ReasonPhrase", "SoapVersion", "IsSoapFault" }; + var actualKeys = MetadataKeySet(projection); + Assert.True(actualKeys.SetEquals(expectedKeys), + $"expected metadata keys {{{string.Join(",", expectedKeys)}}}, got {{{string.Join(",", actualKeys)}}}"); + } + + /// + /// StateStore's whole point: what til-statestore-set writes under + /// til:statestore:roundtrip is exactly what til-statestore-get reads back — the + /// only way to observe a completed task's own result, per the component build report's note + /// that the Task/Action History system function does not expose task response payloads. + /// + [Fact] + public async Task StateStoreRoundTrip_WhatSetWrites_GetReadsBack() + { + var setInstanceId = await RunCaseAsync("case-statestore-set"); + var (setState, setStatus) = await GetInstanceStateAsync(Workflow, setInstanceId); + Assert.Equal("landed", setState); + Assert.NotEqual("F", setStatus); + Assert.Empty(await GetIncidentItemsAsync(setInstanceId)); + + var setProjection = await GetProjectionAsync(setInstanceId); + Assert.Equal(200, NullableLong(setProjection, "tilStatusCode")); + + var getInstanceId = await RunCaseAsync("case-statestore-get"); + var (getState, getStatus) = await GetInstanceStateAsync(Workflow, getInstanceId); + Assert.Equal("landed", getState); + Assert.NotEqual("F", getStatus); + Assert.Empty(await GetIncidentItemsAsync(getInstanceId)); + + var getProjection = await GetProjectionAsync(getInstanceId); + Assert.Equal("case-statestore-get", NullableString(getProjection, "tilCase")); + Assert.Equal(200, NullableLong(getProjection, "tilStatusCode")); + Assert.True(BoolOrFalse(getProjection, "tilHasData"), "the round-trip key should have been found"); + + var tilData = getProjection.GetProperty("tilData"); + Assert.Equal("til-statestore-set", tilData.GetProperty("source").GetString()); + Assert.Equal("til-roundtrip-value", tilData.GetProperty("marker").GetString()); + } + + /// + /// CacheAside's whole point: a miss dispatches til-cache-source and caches its result; a + /// second instance against the same static key reads the cache instead of calling the source + /// again. See this class's remarks for why this specific assertion (unlike the StateStore + /// round-trip above) is not repeated in . + /// + [SkippableFact] + public async Task CacheAsideRoundTrip_MissesThenHits() + { + Skip.If(!await IsMockLabUpAsync(), + $"MockLab is not reachable at {MockLabBaseUrl()} — start it with `docker compose up -d` " + + "in the repo root (or set MOCKLAB_BASE_URL). til-cache-source is a literal " + + "http://localhost:3001 URL (see the component build report) so MOCKLAB_BASE_URL cannot " + + "redirect it."); + + var firstInstanceId = await RunCaseAsync("case-cacheaside"); + var (firstState, firstStatus) = await GetInstanceStateAsync(Workflow, firstInstanceId); + Assert.Equal("landed", firstState); + Assert.NotEqual("F", firstStatus); + + var firstProjection = await GetProjectionAsync(firstInstanceId); + Assert.True(BoolOrFalse(firstProjection, "tilHasData")); + Assert.Equal("til-cache-source-value", firstProjection.GetProperty("tilData").GetProperty("value").GetString()); + + var secondInstanceId = await RunCaseAsync("case-cacheaside"); + var (secondState, secondStatus) = await GetInstanceStateAsync(Workflow, secondInstanceId); + Assert.Equal("landed", secondState); + Assert.NotEqual("F", secondStatus); + + var secondProjection = await GetProjectionAsync(secondInstanceId); + Assert.True(BoolOrFalse(secondProjection, "tilHasData")); + Assert.Equal("til-cache-source-value", secondProjection.GetProperty("tilData").GetProperty("value").GetString()); + + // The second call MUST observe the cache — either as a hit on this pair's own write, or + // (per this class's remarks) because some other case already warmed the same static key + // within its 60s TTL. Either way CacheHit must be true by the second call. + // + // Note the casing: 'tilMetadata' is CacheAsideInvocation.BuildMetadata's raw + // Dictionary (PascalCase keys: StoreName/Key/CacheHit/Refreshed/ETag), but + // instance-data persistence/read-back serializes it with the runtime's camelCase policy — + // dictionary KEYS are data, not property names, so they get camelCased on the wire exactly + // like every other JSON property here (tilCase, tilTaskType, ...). 'tilMetadataKeys' is a + // List captured by TilResultProjection.OutputHandler from the dictionary BEFORE + // that serialization, which is why it (and ResultModelParityTests' key-SET assertions, + // which read that list) still see "CacheHit" verbatim while this nested object needs the + // camelCase spelling. + Assert.True( + secondProjection.GetProperty("tilMetadata").GetProperty("cacheHit").GetBoolean(), + "the second cache-aside call against the same static key should have hit the cache"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/TimeoutLab/README.md b/tests/Core.IntegrationTests/Tests/TimeoutLab/README.md new file mode 100644 index 0000000..016bcc2 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TimeoutLab/README.md @@ -0,0 +1,104 @@ +# Timeout Lab + +The workflow-level `timeout`, end to end: published to the client while it is pending, and honoured +when it fires — including the case where the deadline comes from a parent's +`subFlow.overrides.timeout` rather than the instance's own definition. + +| Class | What it checks | +|---|---| +| `TimeoutLabTests` | The state body's `timeout` block on a root flow and on a SubFlow child, and that the instance actually lands on the `target` it published. | + +## Why it exists + +Two holes, one fixture. + +**The deadline was invisible.** The runtime armed a workflow timeout, persisted the exact instant on +the `InstanceJob` row, and fired it correctly — but no read surface carried it, so a client could not +draw a countdown or say "auto-cancels at HH:MM". That is the ask in +[vnext-client-sdk-core#59](https://github.com/burgan-tech/vnext-client-sdk-core/issues/59). + +**A subflow override was accepted, stamped, and then ignored.** A parent can supply its child's +deadline through `subFlow.overrides.timeout`. That override was read exactly once — when the job was +armed. When the job fired, the handler read the **child's own** definition, found no timeout and +returned. A child declaring `"timeout": null` therefore had a deadline that was scheduled and could +never arrive. `subflow-orchestration` ships that exact shape and no test had ever covered it. + +Both are now answered by one resolver (`InstanceMetadataExtensions.ResolveEffectiveTimeout`) that the +arm, the fire path and the state read all call — so the deadline a client is shown is, by +construction, the one the runtime will act on. These tests are what stops those three drifting apart +again. + +## Why a new flow + +Nothing in this repo exercised a workflow timeout at all, and both authored durations are `PT15M` — +far too long to watch inside a test run. The lab flows use `PT20S`. + +> **Do not shorten `subflow-orchestration-parent.json`'s `PT15M`.** Now that the runtime honours the +> override, a short duration there would start cancelling that scenario's children mid-suite. + +## The flows + +``` +timeout-lab-root timeout-lab-parent + root-waiting ──(manual)──▶ ... parent-initial ──(auto)──▶ parent-subflow + │ │ SubFlow S + │ timeout PT20S │ overrides.timeout PT20S + ▼ ▼ + root-timedout timeout-lab-child + child-waiting ("timeout": null) + │ + ▼ + child-timedout +``` + +Both waiting states are deliberately inert: nothing but the deadline moves the instance, so a failing +test has one possible cause. + +## How to run + +```bash +VNEXT_BASE_URL=http://localhost:4201 dotnet test tests/Core.IntegrationTests --filter "FullyQualifiedName~TimeoutLab" +``` + +Requires the flows to be published (`wf domain use core && wf sync`) against a locally built runtime. + +## Success criteria + +1. A parked instance's state body carries `timeout: { key, target, executeAtUtc }` with a future, + `Z`-designated UTC instant. +2. When the deadline passes, the instance reaches exactly the `target` it published. +3. The block disappears once the instance is terminal — **without** waiting for the asynchronous + `cancel-cleanup` chain to close the job row. +4. A child running under its parent's override reports **the override's** `key`/`target`, not its own + (absent) definition, and is pulled to that target. +5. A parent does not inherit its child's deadline: the block describes the polled instance only. + +## What it caught on its first run + +The scenario failed the first time it was executed, and the failure was not in the fixture. + +`CreateTransitionRecordStep` resolves the virtual `$timeout` key into the audit record's transition +key at **order 20**, through `Workflow.ResolveWellKnownKey` — which *throws* +`TimeoutNotConfiguredForWorkflowException` when the workflow declares no timeout of its own. For a +child running on a parent's override that is exactly the shape, so the pipeline died three steps +before `ApplyTimeoutStateStep` (38), where the fix lived. + +`SetBusy` (19) had already committed by then, so the measured symptom was worse than "the deadline +does not fire": the child was left **Busy in its waiting state forever**, with its job row already +marked processed — no deadline and no way back. A fourth call site now resolves through the shared +effective-timeout resolver; `ResolveWellKnownKey` was left alone, since it is a definition-level +method with no instance in scope. + +Evidence from the database rather than the test summary: `child-timedout` / `C`, audit key +`child-abandoned` (the **parent's** override key), fired ~54 ms after the armed `ExecuteAt`. + +## Deliberately NOT asserted + +- **That activity resets the deadline.** It does not. `timer.reset` is required by the schema and read + nowhere in the runtime, so the duration is an absolute budget from instance start, whatever the + definition declares. Tracked as `workflow-timeout-reset-not-implemented` in + `vnext-meta/known-issues.json`. +- **Timing precision.** The waits allow generous slack over `PT20S`; this scenario proves the deadline + fires and where it lands, not how punctual the scheduler is. +- **The parent's own resume behaviour** after the child times out — that is `subflow-orchestration`'s + subject, not this one. diff --git a/tests/Core.IntegrationTests/Tests/TimeoutLab/TimeoutLabTests.cs b/tests/Core.IntegrationTests/Tests/TimeoutLab/TimeoutLabTests.cs new file mode 100644 index 0000000..32f571d --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/TimeoutLab/TimeoutLabTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.TimeoutLab; + +/// +/// The workflow-level timeout, end to end: published to the client as the state body's +/// timeout block while it is pending, and actually honoured when it fires. +/// +/// +/// +/// Why this scenario exists. Two separate holes, one fixture. (1) The instant was already +/// persisted on the timeout InstanceJob row but no read surface exposed it, so a client could +/// not draw a countdown — the ask in vnext-client-sdk-core#59. (2) A SubFlow child's deadline may +/// come from its parent's subFlow.overrides.timeout, and that override was read only when the +/// job was armed: the job fired on schedule, the handler read the CHILD's own definition, found no +/// timeout and returned. The deadline was scheduled and unreachable. +/// +/// +/// Why a new flow rather than an existing one. Nothing in this repo exercised a workflow +/// timeout at all, and both authored durations are PT15M — unwatchable inside a test run. +/// subflow-orchestration's override must STAY at PT15M: now that the runtime honours +/// it, shortening it would start cancelling that scenario's children mid-suite. +/// +/// +/// What is deliberately NOT asserted. That the deadline resets on activity — it does not. +/// timer.reset is required by the schema and read nowhere in the runtime, so the duration is +/// an absolute budget from instance start. See +/// vnext-meta/known-issues.json → workflow-timeout-reset-not-implemented. +/// +/// +public class TimeoutLabTests : WorkflowTestBase +{ + private const string RootWorkflow = "timeout-lab-root"; + private const string ParentWorkflow = "timeout-lab-parent"; + + private const string RootWaitingState = "root-waiting"; + private const string RootTimedOutState = "root-timedout"; + private const string ChildTimedOutState = "child-timedout"; + + /// The duration both fixtures declare; the waits below allow generous slack over it. + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(20); + + public TimeoutLabTests(VNextTestEnvironment environment) : base(environment) + { + } + + /// + /// While the deadline is pending, the state body carries it: the declared key, the state the + /// instance will be pulled to, and a UTC instant in the future. And when it fires, the instance + /// really lands on that target — the same target the client was shown, which is what the + /// shared effective-timeout resolver guarantees. + /// + [Fact] + public async Task RootFlow_PublishesItsDeadline_AndIsPulledToTheTargetItPublished() + { + var instanceId = await StartAsync(RootWorkflow, new { }); + + await WaitForInstanceStateAsync(RootWorkflow, instanceId, RootWaitingState); + + // ── published while pending ──────────────────────────────────────────── + var timeout = await GetTimeoutBlockAsync(RootWorkflow, instanceId); + + Assert.True(timeout.HasValue, + $"no `timeout` block on a parked instance that declares one — " + + $"{await DescribeAsync(RootWorkflow, instanceId)}"); + + Assert.Equal("root-abandoned", timeout!.Value.GetProperty("key").GetString()); + Assert.Equal(RootTimedOutState, timeout.Value.GetProperty("target").GetString()); + + var executeAtRaw = timeout.Value.GetProperty("executeAtUtc").GetString(); + Assert.False(string.IsNullOrWhiteSpace(executeAtRaw), "executeAtUtc was empty"); + Assert.EndsWith("Z", executeAtRaw, StringComparison.Ordinal); + + var executeAt = DateTimeOffset.Parse(executeAtRaw!, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.True(executeAt > DateTimeOffset.UtcNow, + $"the published deadline {executeAt:O} is already in the past on a freshly started instance"); + + // ── honoured when it fires ───────────────────────────────────────────── + await WaitUntilAsync( + async () => + { + var (state, status) = await GetInstanceStateAsync(RootWorkflow, instanceId); + return state == RootTimedOutState && TerminalStatuses.Contains(status); + }, + $"the timeout never pulled the instance to '{RootTimedOutState}' — " + + $"{await DescribeAsync(RootWorkflow, instanceId)}", + Deadline + TimeSpan.FromSeconds(40)); + + // ── and stops being published once it can no longer fire ─────────────── + var afterwards = await GetTimeoutBlockAsync(RootWorkflow, instanceId); + Assert.False(afterwards.HasValue, + "the `timeout` block is still served on a terminal instance; the read-side guard is " + + "supposed to suppress it without waiting for the asynchronous cancel-cleanup chain to " + + "close the job row"); + } + + /// + /// The regression. The child declares "timeout": null and runs entirely under the + /// parent's subFlow.overrides.timeout. It must BOTH report that override's key/target on + /// its own state read AND actually be pulled to it. + /// + /// + /// Before the effective-timeout resolver, the first assertion had nothing to read (the child's + /// own definition carries no timeout) and the second never happened at all: the job fired and + /// the handler returned on TimeoutConfigMissing. + /// + [Fact] + public async Task SubFlowChild_ReportsAndHonoursTheParentsTimeoutOverride() + { + var parentId = await StartAsync(ParentWorkflow, new { }); + + string childId = null!; + await WaitUntilAsync( + async () => + { + var subflows = await GetActiveSubflowsAsync(ParentWorkflow, parentId); + if (!subflows.TryGetValue("timeout-lab-child", out var id)) return false; + childId = id; + return true; + }, + $"the parent never opened its child correlation — {await DescribeAsync(ParentWorkflow, parentId)}"); + + // ── the child publishes the PARENT's deadline, not its own (it has none) ── + var timeout = await GetTimeoutBlockAsync("timeout-lab-child", childId); + + Assert.True(timeout.HasValue, + "the child serves no `timeout` block, even though its parent supplied one through " + + "subFlow.overrides.timeout — the read is resolving the child's own definition instead " + + "of the effective timeout"); + + Assert.Equal("child-abandoned", timeout!.Value.GetProperty("key").GetString()); + Assert.Equal(ChildTimedOutState, timeout.Value.GetProperty("target").GetString()); + + // ── and the runtime moves it to exactly that target ────────────────────── + await WaitUntilAsync( + async () => + { + var (state, status) = await GetInstanceStateAsync("timeout-lab-child", childId); + return state == ChildTimedOutState && TerminalStatuses.Contains(status); + }, + $"the parent's timeout override never fired on the child — it was armed (the block above " + + $"proves the instant exists) but the instance stayed put. " + + $"{await DescribeAsync("timeout-lab-child", childId)}", + Deadline + TimeSpan.FromSeconds(40)); + } + + /// + /// The timeout block describes the polled instance and nothing else: a parent whose child + /// carries a deadline must not inherit it, and must not be given one it does not have. + /// + [Fact] + public async Task ParentDoesNotInheritItsChildsDeadline() + { + var parentId = await StartAsync(ParentWorkflow, new { }); + + await WaitUntilAsync( + async () => (await GetActiveSubflowsAsync(ParentWorkflow, parentId)).ContainsKey("timeout-lab-child"), + $"the parent never opened its child correlation — {await DescribeAsync(ParentWorkflow, parentId)}"); + + var parentTimeout = await GetTimeoutBlockAsync(ParentWorkflow, parentId); + + Assert.False(parentTimeout.HasValue, + "the parent is serving a `timeout` block; the parent declares no timeout of its own, so " + + "this can only have been lifted from the active subflow — the block is defined to " + + "describe the polled instance only"); + } + + /// + /// Reads the state body's timeout block, or null when the property is absent — which is + /// how "no deadline" is expressed on the wire (the property is omitted, never emitted as null). + /// + private async Task GetTimeoutBlockAsync(string workflow, string instanceId) + { + var response = await Api.CallInstanceFunctionAsync(workflow, instanceId, "state", headers: Headers()); + return response.Body.TryGetProperty("timeout", out var timeout) + && timeout.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined + ? timeout + : null; + } +} diff --git a/tests/Core.IntegrationTests/test.runsettings b/tests/Core.IntegrationTests/test.runsettings index eb55331..f851aab 100644 --- a/tests/Core.IntegrationTests/test.runsettings +++ b/tests/Core.IntegrationTests/test.runsettings @@ -14,6 +14,19 @@ Tests/CrossDomainLab suite skips itself instead of failing. --> http://localhost:4211 + + http://localhost:4231 + + http://localhost:4221