Skripty konektoru: vykonna cast s manifestem a kontrolou parametru

Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese
manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi
pracovat strom automatizace, aniz by o kodu cokoliv vedel.

Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr
sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru
i novy soubor ve slozce funguji stejne a bez restartu.

Pridano:
- scripts/ se skripty konektoru, nazev souboru je zaroven ID operace
- kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
  Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom
  veril parametru, ktery nikdy nedosel
- ctx predavany skriptu: http nad adresou napojeni, util, log, config,
  idempotencyKey, fail a retry. Skript nedostane pristupove udaje
- rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku,
  vzdy vraci vysledek vcetne retryable
- redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty
  token v chybove zprave a log ticketu vidi klient
- napojeni z environment variables vcetne iDokladu
- sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
  services.csbot.cz/apps/idoklad, kazdy na jiny vzor
- stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni.
  Formular testu se sklada z manifestu, nepise se pro kazdy skript
- endpointy /api/dashboard/scripts vcetne Swaggeru

Zmeneno:
- katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
  prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci
  stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript
- ConnectorOperation ma implementation a scriptId
- ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues
- Dockerfile kopiruje scripts/ do vysledneho image

Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni.

K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava,
10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru.

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 13:37:58 +02:00
co-authored by Claude Opus 5
parent bbc2236c0d
commit 6f6b287d7e
34 changed files with 5546 additions and 14 deletions
+3
View File
@@ -14,4 +14,7 @@ EXPOSE 3000
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
# Skripty konektoru jsou obycejny JavaScript, nekompiluji se. Musi se ale
# dostat do image, jinak by konektory nemely zadnou vykonnou cast.
COPY --from=build /app/scripts ./scripts
CMD ["npm", "start"]
+10
View File
@@ -30,6 +30,7 @@ React aplikaci ze slozky `dist/public`.
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
@@ -63,3 +64,12 @@ v [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
Za rozmysleni stoji evidence bugs a wishes. Zamerne to nejsou tickety,
duvod je v [06-tickety.md](06-tickety.md).
Prvni cast navrhu uz je hotova: vykonna cast konektoru, tedy skripty
s manifestem a kontrolou parametru, viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
Runner je pripraveny, chybi nad nim fronta.
Zbytek navrhu je ve dvou souborech, oba jsou navrh k rozhodnuti, ne popis stavu:
[09-navrh-rozsireni.md](09-navrh-rozsireni.md) pro datove modely a prava,
[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) pro frontu, beh kroku
a rozpocet na 150 klientu.
+24
View File
@@ -37,6 +37,11 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/dashboard/tickets/:id/comment` |
| GET | `/api/dashboard/incidents` |
| GET | `/api/dashboard/connectors` |
| GET | `/api/dashboard/scripts` |
| GET | `/api/dashboard/scripts/:id` |
| PUT | `/api/dashboard/scripts/:id` |
| POST | `/api/dashboard/scripts/:id/test` |
| POST | `/api/dashboard/scripts/reload` |
| GET | `/api/dashboard/stream` |
| GET | `/api/dashboard/automations` |
| POST | `/api/dashboard/automations` |
@@ -166,6 +171,25 @@ ticket zustane bez zakaznika i bez resitele a v logu je videt proc.
Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez
zadaneho id pouzije prvni nevyrizeny zaznam.
## Skripty konektoru
Popis modelu je v [11-skripty-konektoru.md](11-skripty-konektoru.md), tady jen API.
Cteni smi kazdy prihlaseny, protoze builder potrebuje vedet, co skript umi.
Uprava, zkusebni spusteni a vynucene nacteni smi **jen spravce platformy** -
uprava skriptu meni chovani vseho, co ho pouziva.
`GET /api/dashboard/scripts` vraci vedle manifestu i `problems` s rozbitymi
skripty a `connections` se stavem napojeni. **Hodnoty pristupovych udaju se
nevraci nikdy**, jen jmena chybejicich environment variables.
`PUT /api/dashboard/scripts/:id` kod nejdriv nacte a overi a az pak prepise
soubor. Rozbita uprava vraci 400 s `issues` a puvodni skript dal funguje.
`POST /api/dashboard/scripts/:id/test` **vola opravdovou sluzbu**. Chyba skriptu
neni chyba API, vraci se 200 a popis v `error` vcetne toho, jestli ma smysl
zkusit to znovu.
## Pri pridani endpointu
Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
# 10 - Runtime, vykonna cast a kapacita
Navrh, ne popis stavu. Runtime neexistuje, dnes se ulozeny strom nevykonava.
Souvisejici navrh datovych modelu je v [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
Tenhle soubor odpovida na tri veci: jak se vyhodnocuji kroky, jak se prijimaji
udalosti a co to znamena pri 150 klientech.
## Nejdriv cisla, pak architektura
Zadani: 150 klientu, kazdy asi 5 systemu, z nich chodi radove desitky udalosti.
To je 750 napojeni. "Desitky udalosti" ma dve cteni a **odpoved se mezi nimi
podstatne lisi**, takze obe:
| Scenar | Desitky udalosti za | Udalosti/den | Kroku/den | Prumer | Spicka |
| ------ | ------------------- | ------------ | ---------- | ------- | -------- |
| A | den a system | 37 tisic | 375 tisic | 4 kr/s | 30-60/s |
| B | hodinu a system | 450 tisic | 4,5 mil | 52 kr/s | 200-400/s|
Pocitano s 10 kroky na udalost, coz je stredni automatizace. Spicka vychazi
z toho, ze provoz je v osmihodinovem okne a uvnitr nerovnomerny, tedy radove
osmkrat nad prumerem.
**Planovat se musi na kroky, ne na udalosti.** Jedna udalost s trisetkrokovym
stromem stoji tristakrat vic nez udalost s jednim krokem. Az bude runtime bezet,
je metrika kroku za sekundu ta jedina, podle ktere se da neco rict.
### Co je a co neni uzke misto
| Vec | Scenar A | Scenar B |
| ----------------------- | --------------- | ------------------------------ |
| Fronta v Postgresu | par procent | zvladne, ale s davkovym odberem|
| Soubezne HTTP volani | 15 soubezne | 90 soubezne, Node se nezapoti |
| **Zapis `run_step`** | 22 GB/mesic | **270 GB/mesic, nutne zkratit**|
| Limity cizich API | uzke misto | uzke misto |
Fronta nad Postgresem s `FOR UPDATE SKIP LOCKED` uklidne obslouzi radove
200 az 500 uloh za sekundu na jednom uzlu, kdyz se odebira davkove. Scenar A
je tedy nezajimavy a scenar B je v pohodlnem pasmu.
**Skutecne uzke misto je objem zapisu.** Radek `run_step` s vstupem a vystupem
v JSONB ma realne 1 az 3 kB. Pri scenari B je to 9 GB denne, coz za pul roku
nikdo neuklidi. Reseni je v sekci o retenci a je to jedina vec z celeho navrhu,
kterou **nelze odlozit na potom**.
Druhe uzke misto je za nasimi hranicemi. iDoklad, WhatsApp ani ekonomicky system
nesnesou desitky pozadavku za sekundu na jeden ucet. Skalovani workeru bez limitu
za napojeni znamena jen rychleji dojit k odpovedi 429.
### Co pri teto velikosti nepotrebujeme
Rict to nahlas, aby se to nestavelo: **zadna Kafka, zadny Redis, zadny Kubernetes,
zadne sharding.** 150 klientu je pro jeden Postgres a par procesu Node maly
provoz. Usili patri do idempotence, spravedlnosti mezi klienty a pozorovatelnosti,
ne do infrastruktury.
Odhad velikosti:
| Scenar | Postgres | Workeri | Kde to bezi |
| ------ | ------------------ | --------------------------- | ----------- |
| A | 4 vCPU, 16 GB | 2 procesy, 50 soubezne | jeden stroj |
| B | 8-16 vCPU, 32 GB, NVMe | 4-6 procesu, 100 soubezne | dva stroje |
## Moznosti u databaze
| Varianta | Verdikt pri 150 klientech |
| -------------------------------- | ------------------------------------------------ |
| Jedno DB, `tenant_id` ve sloupci | **ano, tohle** |
| Schema na klienta | ne: 150 x 20 tabulek je 3000 tabulek, migrace se stanou nespolehlivymi |
| Databaze na klienta | ne, ale nechat si dvere otevrene |
| Partitionovani podle klienta | ne, oddily by byly velikostne nesouvisle |
| Partitionovani podle casu | **ano, u pripisovacich tabulek** |
### Dvere k oddelene databazi za par korun
Jednou prijde klient, ktery bude chtit vlastni databazi, nebo bude delat
tricet procent provozu. Aby to pak nebyla prestavba, staci **jedna vec od zacatku**:
pristup k poolu jen pres `dbFor(tenantId)`, i kdyz zpocatku vraci porad tentyz
pool. K tomu **nikdy nespojovat dotazem dva klienty**, coz uz vynucuje povinny
argument `tenantIds` v ulozistich.
Splneni tehle dvou podminek znamena, ze presun jednoho klienta do vlastni
databaze je konfigurace, ne prepisovani dotazu.
### Pool a jedno pravidlo, na kterem to stoji nebo pada
**Worker nesmi drzet spojeni do databaze po dobu volani ciziho API.** Je to
nejcastejsi zpusob, jak takovy system umre. Volani do iDokladu trva 300 ms.
Kdyz drzi spojeni, znamena 90 soubeznych kroku 90 obsazenych spojeni a pool
skonci.
Spravne poradi:
```
transakce: odeber ulohu (2 ms) - spojeni drzim
uvolni spojeni
volani ciziho API (300 ms) - spojeni nedrzim
transakce: zapis vysledek (2 ms) - spojeni drzim znovu
```
Pri tomhle poradi staci na 90 soubeznych kroku 3 az 5 spojeni. Bez nej 90.
Az bude workeru vic, prijde PgBouncer v transakcnim rezimu. **Pozor: v transakcnim
rezimu nefunguje `LISTEN/NOTIFY`**, a prave na nem ma podle
[09-navrh-rozsireni.md](09-navrh-rozsireni.md) stat sbernice udalosti pro SSE.
Ta potrebuje prime spojeni mimo PgBouncer. Zjistit to az pri nasazeni znamena
rozbity zivy dashboard.
## Cesta udalosti: tri oddelene faze
```
POST /webhook/:token -> event_inbox 202, rychle a hloupe
|
dispatcher udalost na N behu
|
worker krok po kroku
```
Rozdeleni na tri faze neni akademicke. Kazda ma jinou vlastnost: prijem musi byt
rychly, rozeslani musi byt idempotentni, vykonavani musi byt prerusitelne.
### 1. Prijem: rychle a hloupe
```sql
event_inbox(id, tenant_id, token_id, source_kind, source_id,
idempotency_key, payload jsonb, received_at,
status, dispatched_at, cause_run_id, depth)
unique index on (token_id, idempotency_key)
```
Webhook **nesmi vyhodnocovat strom**. Overi token, overi velikost, zapise jeden
radek, vrati 202. Cil je p99 pod 50 ms.
Duvod je praktickeho razu: odesilatel pri timeoutu opakuje. Kdyz webhook ceka
na iDoklad, pomaly iDoklad zpusobi, ze tataz objednavka prijde tri krat.
- **Idempotence pri prijmu.** Hlavicka `Idempotency-Key`, nebo hash tela, kdyz
ji odesilatel neposila. Unikatni index nad `(token_id, idempotency_key)`
v okne 24 hodin. Duplikat vrati 202 a stejne ID udalosti, ne chybu - pro
odesilatele to je uspech, protoze jeho udalost je prijata.
- **Limit za token.** Jeden rozbity klient ve smycce nesmi zaplnit inbox.
- **Strop velikosti tela**, radove 256 kB, vetsi 413.
- **Backpressure.** Kdyz hloubka fronty prekroci hranici, vracet 429 tokenum,
ktere nejsou oznacene jako kriticke. Odesilatele 429 umi, na rozdil od tiche
latence rostouci do minut.
### 2. Vlastni udalosti nechodi pres HTTP
Podle zadani budou udalosti vznikat volanim na webhook z jinych automatizaci.
U cizich odesilatelu ano. **U nasich vlastnich ne.**
Volat vlastni HTTP endpoint na sebe pridava latenci, obsazuje spojeni a zaklada
poruchu, ktera nemusi existovat. Vnitrni udalost zapise radek do `event_inbox`
primo, prochazi tim samym dispatcherem a je v tom samem prehledu. Webhook zustava
pro to, co prichazi zvenci.
### 3. Ochrana proti smycce musi projit skrz udalost
Tohle je nejvaznejsi dusledek toho, ze automatizace vyrabeji udalosti pro jine
automatizace.
`depth` na behu chrani jen **uvnitr jednoho behu**. Kdyz automatizace A vyrobi
udalost, ktera spusti B, a B vyrobi udalost, ktera spusti A, tak kazdy jednotlivy
beh ma hloubku 1 a kontrola nikdy nezasahne. Smycka pobezi, dokud ji nekdo
nevsimne na uctu za cizi API.
Proto `event_inbox` nese `cause_run_id` a `depth`, a plati:
```
depth nove udalosti = depth behu, ktery ji vyrobil, + 1
depth > 5 -> udalost se odmitne, zapise se do logu a upozorni se
```
Bez tohohle jednoho sloupce je navrh z bodu 9 generator nekonecnych smycek.
### 4. Rozeslani
Dispatcher najde automatizace, ktere na dvojici klient a spoustec sedi, a zalozi
beh pro kazdou.
- **Filtr na spousteci se vyhodnoti tady**, jeste pred zalozenim behu. Je to
odpoved na otevrene rozhodnuti z konce [06-tickety.md](06-tickety.md) a pri
tomhle objemu to neni kosmetika: beh, ktery hned skonci, stejne zaplati zapis
do `run`, `run_step` i `ticket_trace`.
- **Jedna transakce**: oznac udalost jako rozeslanou, zaloz behy, zaloz prvni
ulohy. Bud vse, nebo nic.
- **Unikatni index nad `(event_id, automation_id)`.** Kdyz dispatcher padne
uprostred, opakovane rozeslani nezalozi druhy beh.
## Vykonna cast: co ridi prechod mezi kroky
Dve veci, oddelene.
**1. Cista funkce.** `next(tree, path, outputs): FlowPath | null` rozhodne, ktery
krok je dalsi. Zadny stav, zadne IO, testovatelne. Podminka se vyhodnoti nad
`outputs` a vybere vetev. Chuze po strome uz z poloviny existuje ve
`web/src/lib/flow.ts` a `src/data/flowScope.ts`.
**2. Radek v tabulce.** Fakticky prubeh behu drzi zaznam ulohy v databazi,
ne pamet procesu. **Nikdy `setTimeout`, nikdy dlouhy retez promisu, nikdy
rekurze drzici cely beh.** Restart containeru je bezna vec a beh ho musi prezit.
```sql
run(id, tenant_id, source_kind, source_id, source_version,
event_id, trigger_type, status, depth, queue_key,
started_at, finished_at, error)
run_step(id, run_id, path, step_id, attempt, status,
input jsonb, output jsonb, error, started_at, finished_at)
job(id, run_id, tenant_id, next_path, run_after, attempts,
queue_key, locked_by, locked_until, priority)
```
`source_kind` a `source_version` rikaji, jestli beh patri automatizaci nebo akci
a podle ktere verze jeji definice se ma dokoncit. To je to, co dela z cekaciho
kroku fungujici vec: beh cekajici tyden dobehne podle stromu, ktery platil pri
jeho spusteni.
`run_after` v tabulce `job` je zaroven **cele cekani z bodu 8**. Krok `wait` neni
v runtimu vyjimka, je to obycejny krok, ktery misto volani sluzby nastavi
`run_after` a skonci. Proto ten bod skoro nic nestoji.
### Smycka workeru
```
1. transakce: odeber DAVKU uloh
SELECT ... FROM job
WHERE run_after <= now()
AND (locked_until IS NULL OR locked_until < now())
AND tenant_id <> ALL (:klienti_na_stropu)
ORDER BY priority, run_after
FOR UPDATE SKIP LOCKED LIMIT 25
UPDATE job SET locked_by = :worker, locked_until = now() + interval '2 min'
2. uvolni spojeni, vykonej ulohy soubezne, kazda PRAVE JEDEN krok
3. za kazdou ulohu transakce: zapis vysledek kroku
smaz hotovou ulohu
zarad dalsi ulohu podle next()
```
**Krok 3 v jedne transakci je cely trik.** Bud se zapise vysledek i dalsi uloha,
nebo nic. Nikdy nevznikne beh, ktery ma hotovy krok a nema pokracovani, ani
dvakrat zarazeny stejny krok.
**Davkovy odber je nejvetsi pacidlo na propustnost.** Po jedne uloze znamena pri
300 krocich za sekundu 300 odberovych transakci za sekundu. Po dvaceti peti
je jich dvanact. Je to jedna zmena `LIMIT` a nekolikanasobne mensi zatez.
`SKIP LOCKED` znamena, ze workeru muze byt libovolne mnoho a nepotrebuji
koordinatora. Za zvazeni stoji `graphile-worker` - je to tentyz princip nad
Postgresem, overeny provozem. Rucne az kdyz bude potreba spravedlnost podle
`queue_key`, kterou hotova knihovna neresi.
### Spravedlnost mezi klienty
Ciste FIFO znamena, ze jeden vecerni import u jednoho klienta zastavi ostatnich
149. Pri 150 klientech to neni hypoteza, je to otazka casu.
Prakticky pouzitelna verze je dvojice:
- **Semafor za klienta ve workeru**: nejvyse N soubeznych kroku na klienta.
- **Odberovy dotaz preskoci klienty na stropu** (`tenant_id <> ALL (...)`).
Seznam si worker drzi sam a je aktualni na jednu davku.
Presna spravedlnost cistym SQL je slozita a nevyplati se. Tohle je odhadem
o dva rady jednodussi a rozdil nikdo nepozna.
K tomu dve dalsi hranice:
- **Limit a rychlostni strop za napojeni, ne za konektor.** Kvota je na uctu
klienta v iDokladu, ne na tom, ze iDoklad existuje. Zetonovy kosik jednim
`UPDATE ... RETURNING` nad radkem napojeni.
- **Serializace nad jednim ticketem.** `queue_key = ticket:<id>` a jen jedna
bezici uloha na klic. Bez toho dve automatizace prepisuji stav teze veci
a poradi neni dane.
### Pady
- **Lease.** Worker padne uprostred kroku, `locked_until` vyprsi, ulohu si vezme
jiny. Nic se neztrati.
- **Kroky musi byt idempotentni.** Kazdy krok dostane
`idempotencyKey = runId + ':' + path`, **stabilni pres vsechny pokusy**.
Konektory, ktere umi `Idempotency-Key`, ho dostanou a druhy pokus nevystavi
druhou fakturu. Klic za pokus by byl k nicemu, o tom to cele je.
- **Retry.** Exponencialni backoff s jitterem, radove 5 pokusu. Rozlisit
opakovatelne (timeout, spojeni, 429, 5xx) od koncovych (400, 401, 403,
validace). Koncovou chybu neopakovat, jen se tim vypali kvota.
- **Po vycerpani pokusu** dostane beh stav `failed`, zapise se do logu ticketu
a vznikne udalost. Beh zustane a **lze ho pokracovat od padleho kroku**,
protoze stav je per krok, ne per beh.
- **Limit kroku na beh** a globalni timeout behu.
- **Exactly-once neexistuje.** Cil je at-least-once plus idempotence. Kdo slibi
exactly-once, jen jeste nenasel pripad, kdy to nedrzi.
## Kde bezi skripty
Skripty z bodu 9 jsou cisty prevod dat, ale i tak maji vlastni provozni pravidlo,
a je dulezite:
**Skript nesmi bezet v hlavnim vlakne workeru.** Skript, ktery pocita
pet set milisekund, zablokuje smycku udalosti a s ni **vsechny ostatni soubezne
kroky toho workeru**. Jeden nepovedeny cyklus u jednoho klienta tim zastavi
provoz vsech ostatnich, a v logu to vypada jako pomala cizi API.
Navrh:
- Bazen `worker_threads`, v kazdem `isolated-vm`. Radove tolik vlaken, kolik je
jader.
- Tvrdy timeout 50 az 200 ms a **zabiti vlakna** pri prekroceni, ne zdvorile
preruseni. Prerusit smycku `while (true)` jinak nejde.
- Zkompilovany skript se drzi v cache za verzi, kontext se po N spustenich zahodi
kvuli unikum pameti.
- Rezie kontextu je radove milisekunda, takze tisic skriptovych kroku za sekundu
neni problem.
Kdyz nekdy bude potreba skript, ktery neco vola nebo dlouho pocita, nedostane
vic pravomoci. Stane se **vlastni sluzbou v AppFactory** a v katalogu konektorem,
ktery ji vola. Tim pro nej zacne platit retry, rate limit i audit jako pro kazdy
jiny krok.
Skripty se nikdy nespousti v procesu API. Portal nesmi zpomalit kvuli tomu,
ze nekdo ulozil spatny cyklus.
## Retence a objem, hned pri navrhu schematu
Tohle je jedina vec, ktera pri scenari B rozhoduje o tom, jestli to za pul roku
jde provozovat.
**Zkracovani obsahu.** Plny vstup a vystup kroku se uklada jen u kroku, ktere
selhaly, plus u male vzorku uspesnych. U ostatnich se uklada velikost, hash
a prvnich radove 512 bajtu. Snizi to objem radove desetkrat a neztrati to nic,
co by nekdo cetl - do uspesneho kroku se nikdo nechodi divat.
**Partitionovani po mesicich** u pripisovacich tabulek: `run_step`,
`ticket_trace`, `event_inbox`, `audit`. Mazani stareho oddilu je pak
`DROP TABLE`, ne `DELETE` bezici pres noc.
**Retence** podle toho, kdo to cte:
| Data | Jak dlouho |
| --------------------- | ----------------- |
| Vstupy a vystupy kroku| 30 dni |
| Souhrn behu | 12 mesicu |
| Log ticketu | 90 dni |
| Audit | dele, dane pravni potrebou |
Cisla patri do nastaveni za klienta, protoze delsi retence je dobry duvod
pro drazsi tarif.
## Co se monitoruje
Ne CPU. Ctyri veci, a kazda odpovida na jinou otazku:
| Metrika | Odpovida na |
| ----------------------------------- | --------------------------------- |
| Hloubka fronty | stiha se to |
| **Vek nejstarsi pripravene ulohy** | je to zahlcene, nebo zaseknute |
| Kroku za sekundu, p95 za konektor | kde to drhne |
| Padle behy za hodinu, podil opakovani| co je rozbite |
| Podil kroku za klienta | kdo je hlucny soused |
| Zpozdeni inboxu (prijato az rozeslano) | stiha dispatcher |
Bez veku nejstarsi ulohy se neda odlisit "je hodne prace" od "nic se nedeje",
a to jsou dva uplne jine problemy se stejnou hloubkou fronty.
## Kdy zmenit architekturu
Aby se to nemuselo rozhodovat dopredu. Do te doby plati navrh vyse.
| Signal | Co udelat |
| ---------------------------------------- | ------------------------------------- |
| Fronta zere nad 30 % CPU databaze | vetsi davky, pak fronta v Redisu (BullMQ) |
| Zapisy `run_step` prevalcuji IO | zkratit obsah, vzorkovat, velka tela do objektoveho uloziste |
| Jeden klient dela nad 30 % provozu | vlastni bazen workeru, pak vlastni databaze |
| Fronta roste kazdy den ve spicce | pridat workery, jsou bezstavove |
| Prevazuji chyby 429 z cizich API | limity za napojeni, pak vyjednat kvoty|
| Cekajici behy jdou do stovek tisic | oddelena fronta pro dlouha cekani, aby nezdrzovala bezny odber |
## Jeden container, dve role
AppFactory nasazuje jednu aplikaci, takze worker nebude zvlastni sluzba.
Rozdelit ho **procesne uvnitr image** pres `APP_ROLE=api|worker|both`
s vychozim `both`. Az bude spicka takova, ze behy zpomaluji portal, nasadi se
druha instance s `APP_ROLE=worker`. Zmena je jedna environment variable, zadny
zasah do infrastruktury AppFactory.
Migrace pri startu potrebuji poradovy zamek (`pg_advisory_lock`), aby je pri
soubeznem nasazeni nespustilo vic instanci najednou.
## Poradi, v jakem to stavet
1. `event_inbox`, webhook, idempotence pri prijmu, `depth` skrz udalost.
Bez toho zbytek nema co zpracovavat a smycky jsou otevrene.
2. `run`, `run_step`, `job`, `executeStep`, smycka po jedne uloze.
Nejmensi verze, ktera vykona strom.
3. Retry, lease, idempotency key ke konektorum.
4. Dispatcher s filtrem na spousteci.
5. Krok `wait` a prehled cekajicich behu.
6. Davkovy odber, semafor za klienta, limity za napojeni.
7. Zkracovani obsahu, partitionovani, retence.
8. Bazen vlaken pro skripty.
Body 1 az 3 jsou nutne, aby vubec neco bezelo. Body 6 a 7 jsou to, co odlisuje
scenar A od scenare B, a **daji se dodelat pozdeji bez prestavby** - vyzaduji ale,
aby uz od zacatku existovaly sloupce `tenant_id` a `queue_key` v tabulce `job`
a partitionovani u `run_step`. Pridat oddily do nejvetsi tabulky v systemu az
potom je ta jedina cast, ktera by opravdu bolela.
+315
View File
@@ -0,0 +1,315 @@
# 11 - Skripty konektoru
Tohle uz neni navrh, je to naprogramovane. Navrh, ze ktereho to vzniklo, je
v [09-navrh-rozsireni.md](09-navrh-rozsireni.md), bod 9.
## Co to je
Skript je **vykonna cast konektoru**. Jeden soubor, ktery nese dve veci:
- **manifest** - jak se operace jmenuje, co potrebuje na vstupu, co vraci na vystupu,
- **kod** - co se ma opravdu udelat.
Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv
vedel. Builder z manifestu vykresli pole kroku a podminka za krokem se muze
zeptat na jeho vystupy.
## Kde to je
```
scripts/ soubory skriptu, obycejny JavaScript
_sablona.js sablona ke zkopirovani (podtrzitko = nenacita se)
idoklad.get-issued-invoice.js
...
src/scripts/types.ts co je skript, zod schema manifestu
src/scripts/values.ts kontrola vstupu a vystupu
src/scripts/util.ts pomocne funkce pro skripty, redakce tajemstvi
src/scripts/connections.ts kam se vola a cim se to autorizuje
src/scripts/http.ts HTTP klient predany skriptu
src/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu
src/scripts/registry.ts nacitani ze souboru, hot reload, ukladani
src/scripts/runner.ts spusteni jednoho skriptu
src/routes/scripts.ts API
web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty
```
## Nic se neotaci
Soubory jsou zamerne **obycejny JavaScript, ne TypeScript**. TypeScript by se
musel prelozit, a to je presne to otaceni, ktere tady nema byt.
Registr si drzi cas zmeny souboru a pri zmene ho nacte znovu. Prohledava
nejvyse jednou za sekundu, takze cteni katalogu neznamena stat na kazdy dotaz.
Uprava tedy funguje trema cestami a vzdy stejne:
| Kudy | Co se stane |
| -------------------------------- | -------------------------------------------- |
| Editor v portalu | ulozi soubor, registr ho nacte hned |
| Rucni uprava souboru na serveru | registr si zmeny vsimne pri dalsim dotazu |
| Novy soubor ve slozce | objevi se jako nova operace v katalogu |
`POST /api/dashboard/scripts/reload` to jen vynuti hned, bez cekani.
## Nazev souboru je ID
Soubor se jmenuje `<konektor>.<operace>.js` a `manifest.id` musi byt stejne.
Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem
a nacetl pod druhym.
```
scripts/idoklad.get-issued-invoice.js
\_____/ \________________/
konektor operace
```
Z ID se dopocita, do ktereho konektoru operace patri, takze se to nepise
dvakrat. Konektor **musi existovat** v `src/data/connectors.ts`, jinak se skript
ohlasi jako problem.
## Manifest
```js
export const manifest = {
id: 'idoklad.get-issued-invoice',
name: 'Získat vydanou fakturu',
description: 'Načte vydanou fakturu z iDokladu podle jejího ID.',
timeoutMs: 15000, // nepovinne
inputs: [ /* ScriptField */ ],
outputs: [ /* ScriptField */ ],
};
```
Parametr je pro vstup i vystup **tentyz tvar**. Kontrola je pak jedna funkce,
ne dve skoro stejne, ktere by se casem rozesly.
| Klic | K cemu |
| ----------- | ------------------------------------------------------------- |
| `id` | pouziva se v sablonach jako `{{id}}`, jen pismena a podtrzitka |
| `label` | co vidi uzivatel v builderu |
| `type` | `string`, `number`, `boolean`, `date` |
| `required` | u vstupu: bez hodnoty se skript nespusti. U vystupu: musi ho vratit |
| `hint` | napoveda pod polem |
| `options` | vyber z hodnot, jina neprojde |
| `pattern` | dalsi kontrola regularnim vyrazem (jen `string`) |
| `multiline` | pole na vic radku (jen `string`) |
| `default` | dosadi se, kdyz hodnota chybi a parametr neni povinny |
Schema manifestu je `.strict()`. Preklep v nazvu klice (`outputFileds`) se ohlasi,
ne tise ignoruje.
## Kontrola vstupu a vystupu
Poradi je vzdy stejne: **overit vstup, spustit, overit vystup**.
Overeni vystupu neni pridavek. Bez nej by strom veril parametru, ktery nikdy
nedosel, a podminka za krokem by se rozhodovala podle `undefined`.
Pravidla:
- povinny parametr bez hodnoty je chyba, ne prazdny retezec,
- nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
- hodnota se prevede na deklarovany typ, kdyz to jde bez hadani. Ceska
desetinna carka projde, `"ano"` u typu boolean taky,
- parametr, ktery v manifestu neni, se zahodi a zaloguje. Stejne jako u webhooku:
odesilatele posilaji i vlastni data a odmitat je by rozbijelo integrace,
- chyby se vraci **vsechny najednou**, ne jen prvni.
Chybejici povinny vystup je chyba **skriptu**, ne uzivatele, a hlasi se jinym
druhem (`output`).
## Co skript ma a co nema
Skript ma jen `ctx`. Zadny import, zadny pristup na sit mimo `ctx.http`
a **zadne pristupove udaje**.
```js
export async function run(inputs, ctx) { /* ... */ }
```
| Na kontextu | K cemu |
| ------------------ | ---------------------------------------------------------- |
| `ctx.http` | `get`, `post`, `patch`, `put`, `del` nad adresou napojeni |
| `ctx.util` | pomocne funkce, viz nize |
| `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny |
| `ctx.config` | necitliva cast nastaveni napojeni |
| `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
| `ctx.fail` | koncova chyba, neopakuje se |
| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
Adresu i autorizacni hlavicky doplnuje runtime podle napojeni. Skript rika
`GET /issued-invoices/12` a nic vic. Duvod je v bodu 9 navrhu: kdyby skript
znal tajemstvi, staci jeden `ctx.log` a je v logu, ktery vidi klient.
### Pomocne funkce
Cizi API vraci pokazde jinak. iDoklad pouziva velka pocatecni pismena a nekde
obaluje odpoved do `Data`. Bez tehle sady by to kazdy skript resil znovu a jeden
z nich by to resil spatne.
| Funkce | Co dela |
| -------------------------- | -------------------------------------------------- |
| `unwrap(body)` | rozbali `{ Data: x }` i `{ data: x }` |
| `pick(obj, ...names)` | prvni existujici pole bez ohledu na velikost pismen |
| `first(value)` | prvni prvek pole, nebo null |
| `text`, `num`, `bool`, `date` | prevody s fallbackem |
| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
## Chyby: opakovatelne a koncove
Rozdeleni je to podstatne. Timeout nebo 503 ma smysl zkusit znovu, spatny vstup
nebo 403 ne - opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
| Druh | Kdy | Opakovat |
| ------------ | ------------------------------------------ | -------- |
| `not_found` | skript neexistuje | ne |
| `config` | chybi pristupove udaje, 401, 403 | ne |
| `validation` | vstup neprosel kontrolou | ne |
| `output` | skript nevratil deklarovany vystup | ne |
| `terminal` | 400, 404, jina koncova odpoved sluzby | ne |
| `retryable` | 408, 429, 5xx, chyba spojeni | ano |
| `timeout` | skript nedobehl v limitu | ano |
| `internal` | neocekavana vyjimka ve skriptu | ne |
Runner **nikdy nevyhodi vyjimku**. Vzdy vrati vysledek s `ok`, `outputs`, `logs`,
`durationMs`, `httpCalls` a pripadne `error` vcetne `retryable`. Az bude
existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku.
## Napojeni
Zatim jedno napojeni na konektor, sestavene z environment variables. Cilovy stav
je napojeni za firmu v databazi, viz bod 9 navrhu. Az to bude, prepise se vnitrek
`resolveConnection` a nic dalsiho.
| Promenna | K cemu |
| --------------------------- | --------------------------------------------------- |
| `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` |
| `<KONEKTOR>_BASE_URL` | presmerovani jednoho konektoru |
| `IDOKLAD_CLIENT_ID` | povinne pro iDoklad, jde do `X-ClientId` |
| `IDOKLAD_CLIENT_SECRET` | povinne pro iDoklad, jde do `X-ClientSecret` |
| `IDOKLAD_APPLICATION_ID` | jen partnerske aplikace |
| `SCRIPTS_DIR` | jina slozka se skripty |
| `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 |
| `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 |
| `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** |
Autorizace konektoru je popsana v `authSpecs` v `src/scripts/connections.ts`.
Novy konektor s pristupovymi udaji znamena jeden zaznam v teto tabulce.
**Hodnoty se z API nikdy nevraci.** `GET /api/dashboard/scripts` posila jen jmena
chybejicich promennych a jmena vyplnenych hlavicek, nikdy hodnoty (AGENTS.md).
## Redakce tajemstvi
Cizi API rado vraci prijaty token v chybove zprave. Log ticketu ukazuje, co
sluzba vratila, a zobrazuje se klientovi. Proto vsechno, co jde do logu nebo do
chyby, projde nahradou znamych tajnych hodnot za hvezdicky.
Neni to volitelne dolazeni, je to soucast zapisu.
## Bezpecnostni hranice a co jeste chybi
Skripty ve slozce jsou **nase**, prosly gitem a code review. Bezi proto v procesu
serveru, ne v sandboxu. Plati pro ne:
- **nemaji sit mimo `ctx.http`** a v nem nesmi mirit do vnitrni site
(`ALLOW_PRIVATE_TARGETS` je jen pro vyvoj),
- **nedostanou pristupove udaje**,
- timeout je hlidany pres `AbortSignal`, takze prerusi cekani na sit.
Co to **neresi**: skript s `while (true)` timeout nezastavi. Runner vrati chybu,
ale smycka bezi dal a blokuje hlavni vlakno. U nasich skriptu je to prijatelne,
u zakaznickych ne - ti musi bezet v izolovanem enginu ve vlastnim vlakne.
Podrobnosti v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md), sekce
o skriptech.
## Napojeni do katalogu
Skript se domeri do katalogu konektoru jako akce s `implementation: 'script'`
a `scriptId`. Kdyz nese ID operace, ktera uz v katalogu je, **skript vyhrava** -
staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
Prekryv drzi `src/data/connectors.ts` (`setScriptActions`, `actionsFor`).
Je to zamerne tam, protoze vsechno ostatni se uz pta pres `findOperation`.
Tim se skripty naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem
kroku videt, i v sablonach - bez toho, aby se to psalo trikrat.
V portalu jsou operace se skriptem oznacene ikonou v katalogu konektoru.
## API
| Metoda | Cesta | Kdo smi |
| ------ | ----------------------------------------- | ---------------- |
| GET | `/api/dashboard/scripts` | prihlaseny |
| GET | `/api/dashboard/scripts/:id` | prihlaseny |
| PUT | `/api/dashboard/scripts/:id` | spravce platformy |
| POST | `/api/dashboard/scripts/:id/test` | spravce platformy |
| POST | `/api/dashboard/scripts/reload` | spravce platformy |
Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi. Uprava meni
chovani vseho, co skript pouziva, takze to neni pravo vedle prava zakladat tickety.
**Ukladani je bezpecne proti rozbiti.** Nejdriv se kod zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni. Rozbita uprava se
neulozi a skript, ktery fungoval, funguje dal. Co presne nesedi, prijde
v `issues`.
**Zkusebni spusteni vola opravdovou sluzbu.** Vystavena faktura opravdu vznikne.
Zamerne: test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
Portal na to upozornuje nad tlacitkem.
## Ukazkove skripty pro iDoklad
Postavene proti skutecnemu API sluzby na `https://services.csbot.cz/apps/idoklad`.
Kazdy ukazuje jiny vzor, at je z ceho vychazet.
| Skript | Vzor |
| --------------------------------- | --------------------------------------------- |
| `idoklad.get-issued-invoice` | jedno volani a prevod odpovedi |
| `idoklad.find-issued-invoice` | predvalidace: nenalezeno **neni** chyba |
| `idoklad.find-contact` | vlastni kontrola vstupu (aspon jedno z dvojice) |
| `idoklad.create-issued-invoice` | dve volani, vzor z `/default` a prepis jen znamych poli |
| `idoklad.register-payment` | akce, ktera meni stav, plus idempotence |
| `idoklad.send-invoice-email` | odpoved nic nevraci, vystup se sklada ze vstupu |
### Proc se u zakladani bere vzor z `/default`
iDoklad u faktury vyzaduje pole, ktera nikdo rucne vyplnovat nechce
(`documentSerialNumber`, `isEet`, `isIncomeTax`). Skript proto nejdriv vezme
predvyplneny vzor z `GET /issued-invoices/default` a prepise v nem **jen to,
cemu rozumime**.
Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli na
strane iDokladu.
### Idempotence
Kazde volani nese hlavicku `Idempotency-Key` s hodnotou `ctx.idempotencyKey`.
Klic je pro tentyz krok **stabilni pres vsechny pokusy**, takze druhy pokus
po timeoutu nevystavi druhou fakturu. Klic za pokus by byl k nicemu, o tom to
cele je.
## Jak pridat skript
1. Zkopirovat `scripts/_sablona.js` na `<konektor>.<operace>.js`.
2. Srovnat `manifest.id` s nazvem souboru.
3. Vyplnit `inputs` a `outputs`.
4. Napsat `run`.
5. Kdyz konektor jeste neni v `src/data/connectors.ts`, pridat ho.
6. Kdyz potrebuje pristupove udaje, pridat zaznam do `authSpecs`
v `src/scripts/connections.ts`.
Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
## Co chybi
| Chybi | Poznamka |
| ---------------------------- | --------------------------------------------------- |
| Napojeni za firmu | zatim jedno na konektor z environment variables |
| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
| Verzovani skriptu | uprava prepise soubor, historie je jen v gitu |
| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni |
| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime |
| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
+64
View File
@@ -2,6 +2,70 @@
Nejnovejsi nahore.
## 2026-08-12 - skripty konektoru
Naprogramovana vykonna cast konektoru. Popis je
v [11-skripty-konektoru.md](11-skripty-konektoru.md).
### Pridano
- `scripts/` se skripty konektoru. Jeden soubor nese manifest (vstupni a vystupni
parametry) i kod. Obycejny JavaScript, aby se nemusel prekladat.
- Hot reload podle casu zmeny souboru. Uprava v portalu i rucni uprava souboru
se projevi bez restartu.
- Kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
Chybejici povinny vystup je chyba skriptu, ne uzivatele.
- `ctx` predavany skriptu: `http` nad adresou napojeni, `util`, `log`, `config`,
`idempotencyKey`, `fail` a `retry`. Skript nedostane pristupove udaje.
- Rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy
vraci vysledek vcetne `retryable`.
- Redakce tajnych hodnot pred zapisem do logu.
- Napojeni z environment variables (`src/scripts/connections.ts`) vcetne iDokladu.
- Sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
`services.csbot.cz/apps/idoklad`, kazdy na jiny vzor.
- Stranka `/dashboard/skripty`: seznam, manifest, editor, zkusebni spusteni.
Formular testu se sklada z manifestu, nepise se pro kazdy skript.
- Endpointy `/api/dashboard/scripts`, `/:id`, `PUT /:id`, `/:id/test` a `/reload`.
Vse ve Swaggeru.
### Zmeneno
- Katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
prekryvem v `src/data/connectors.ts`, takze se naraz objevi ve validaci stromu,
ve vypoctu scope i v sablonach. Pri stejnem ID operace vyhrava skript.
- `ConnectorOperation` ma `implementation` a `scriptId`. Katalog v portalu operace
se skriptem oznacuje ikonou.
- `ApiError` na klientovi nese cele telo odpovedi a umi z nej vytahnout `issues`.
- Dockerfile kopiruje `scripts/` do vysledneho image.
### Vedome neudelano
Skripty bezi v procesu serveru, ne v sandboxu. Jsou nase a prosly gitem.
Zakaznicke skripty budou potrebovat izolovany engine ve vlastnim vlakne, duvod
je v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
Ulozeni z portalu zapisuje do souboru v containeru. Bez trvaleho svazku ho
redeploy vrati na verzi z gitu.
## 2026-08-12 - navrhy
Pridany [09-navrh-rozsireni.md](09-navrh-rozsireni.md)
a [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
09 popisuje datove modely: akce navazane na typ nebo tag ticketu s telem jako
operaci, vlastnim stromem nebo skriptem, typy a tagy ticketu, role a prava jako
data misto unionu, zalozky a zpristupneni konektoru za firmu, konektory rozdelene
na definici, zpristupneni a napojeni, cekaci krok, sablony zprav, vlastni widgety
se seskupovanim a prevod na Postgres. Soucasti je kontrola navrhu proti celemu
prikladu se dvema firmami jednoho cloveka.
10 popisuje vykonnou cast: cestu udalosti od webhooku pres inbox a dispatcher
k workeru, frontu v Postgresu se `SKIP LOCKED`, davkovy odber, spravedlnost mezi
klienty, idempotenci, retence a rozpocet na 150 klientu ve dvou scenarich objemu.
Nic z toho neni naprogramovane, oba dokumenty jsou navrh k rozhodnuti.
Kod se nemenil.
## 2026-08-03
Tickety predelane na plnohodnotny konektor. Prestavaji byt polozkou v seznamu
+83
View File
@@ -0,0 +1,83 @@
/**
* Sablona noveho skriptu. Soubory od podtrzitka se nenacitaji, takze tenhle
* nikde nevznikne jako operace - je tu jen ke zkopirovani.
*
* Postup:
* 1. zkopirovat na `<konektor>.<operace>.js`, napriklad `idoklad.get-contact.js`,
* 2. srovnat `manifest.id` s nazvem souboru, musi byt stejne,
* 3. vyplnit vstupy a vystupy,
* 4. napsat `run`.
*
* Nic se nerestartuje. Server si zmenu vsimne podle casu souboru a nacte ji
* pri dalsim dotazu. Totez plati pri uprave v portalu.
*
* Co ma skript k dispozici je jen `ctx`. Zadny import, zadny pristup k sitim
* mimo `ctx.http` a zadne pristupove udaje - ty dosazuje runtime podle napojeni.
*/
export const manifest = {
/** Musi odpovidat nazvu souboru bez .js. */
id: 'konektor.operace',
name: 'Nazev, ktery uvidi uzivatel v builderu',
description: 'Jedna veta o tom, co se stane. Cte to clovek, ktery staví strom.',
/**
* Co skript potrebuje. Presne tohle se v builderu vykresli jako pole kroku
* a server to pred spustenim overi.
*
* type: 'string' | 'number' | 'boolean' | 'date'
* required: true = bez hodnoty se skript vubec nespusti
* options: vyber z hodnot, jina neprojde
* multiline: pole na vic radku
* default: dosadi se, kdyz hodnota chybi a pole neni povinne
* pattern: dalsi kontrola regularnim vyrazem (jen u string)
*/
inputs: [
{
id: 'prikladVstupu',
label: 'Příklad vstupu',
type: 'string',
required: true,
hint: 'Napoveda pod polem.',
},
],
/**
* Co skript vraci. Tohle je to, s cim pak umi pracovat strom - podminka se
* na to muze zeptat a sablona to muze dosadit jako `{{prikladVystupu}}`.
*
* Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by
* jinak veril parametru, ktery nikdy nedosel.
*/
outputs: [
{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true },
],
};
/**
* @param {Record<string, string | number | boolean | null>} inputs
* Uz overene a prevedene na typy z manifestu.
* @param {{
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function },
* util: { unwrap: Function, pick: Function, first: Function, text: Function,
* num: Function, bool: Function, date: Function, round: Function, need: Function },
* log: Function, config: Record<string, string>, idempotencyKey: string,
* fail: Function, retry: Function,
* }} ctx
*/
export async function run(inputs, ctx) {
// Cesta je relativni k adrese napojeni, cela adresa se nikam nepise.
const { body } = await ctx.http.get('/nejaky-endpoint', {
query: { hledat: inputs.prikladVstupu },
});
const data = ctx.util.unwrap(body);
// ctx.fail = koncova chyba, neopakuje se.
// ctx.retry = docasna chyba, runtime to zkusi znovu.
if (!data) ctx.fail('Služba nic nevrátila.');
return {
prikladVystupu: ctx.util.need(ctx.util.text(ctx.util.pick(data, 'nazev')), 'název'),
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* iDoklad: vystaveni vydane faktury s jednou polozkou.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-invoices/default, POST /issued-invoices
*
* Vzor **dvou volani za sebou**. iDoklad u faktury vyzaduje pole, ktera nikdo
* rucne vyplnovat nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`),
* takze se nejdriv vezme predvyplneny vzor z `/issued-invoices/default`
* a prepisou se v nem jen ty veci, ktere prisly ze stromu.
*
* Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli
* na strane iDokladu. Takhle se prepisuje jen to, cemu rozumime.
*/
export const manifest = {
id: 'idoklad.create-issued-invoice',
name: 'Vystavit vydanou fakturu',
description:
'Vystaví v iDokladu vydanou fakturu s jednou položkou. Chybějící údaje ' +
'se doplní z předvyplněného vzoru iDokladu.',
timeoutMs: 25000,
inputs: [
{
id: 'partnerId',
label: 'ID odběratele v iDokladu',
type: 'number',
required: true,
hint: 'Umí ho dohledat akce Najít kontakt.',
},
{
id: 'description',
label: 'Popis dokladu',
type: 'string',
required: true,
hint: 'Text v hlavičce faktury, například Objednávka {{orderNumber}}.',
},
{ id: 'itemName', label: 'Název položky', type: 'string', required: true },
{
id: 'unitPrice',
label: 'Cena za jednotku',
type: 'number',
required: true,
hint: 'V měně dokladu. Desetinná čárka i tečka projdou.',
},
{ id: 'amount', label: 'Počet jednotek', type: 'number', required: false, default: 1 },
{ id: 'unit', label: 'Jednotka', type: 'string', required: false, default: 'ks' },
{
id: 'dateOfIssue',
label: 'Datum vystavení',
type: 'date',
required: false,
hint: 'Nevyplněno = dnes.',
},
{
id: 'maturityDays',
label: 'Splatnost ve dnech',
type: 'number',
required: false,
default: 14,
},
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
{ id: 'note', label: 'Poznámka', type: 'string', required: false, multiline: true },
{
id: 'vatRateType',
label: 'Kód sazby DPH',
type: 'number',
required: false,
default: 0,
hint: 'Číselný kód VatRateType z iDokladu. Když nevíte, nechte 0.',
},
{
id: 'priceType',
label: 'Kód typu ceny',
type: 'number',
required: false,
default: 0,
hint: 'Číselný kód PriceType z iDokladu (cena s DPH nebo bez). Když nevíte, nechte 0.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
],
};
/** Datum ve tvaru, ktery iDoklad ceka. */
function isoDay(value) {
return new Date(value).toISOString().slice(0, 10);
}
function addDays(value, days) {
const date = new Date(value);
date.setUTCDate(date.getUTCDate() + days);
return date;
}
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, date, need } = ctx.util;
if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.');
const amount = inputs.amount ?? 1;
if (amount <= 0) ctx.fail('Počet jednotek musí být větší než nula.');
// 1. Predvyplneny vzor. Nese povinna pole, ktera nechceme vyplnovat rucne.
const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body);
if (!defaults || typeof defaults !== 'object') {
ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.');
}
const issuedAt = inputs.dateOfIssue ? new Date(inputs.dateOfIssue) : new Date();
const maturityAt = addDays(issuedAt, inputs.maturityDays ?? 14);
// 2. Prepisou se jen ta pole, kterym rozumime. Zbytek zustava ze vzoru.
const payload = {
...defaults,
partnerId: inputs.partnerId,
description: inputs.description,
dateOfIssue: isoDay(issuedAt),
dateOfTaxing: isoDay(issuedAt),
dateOfMaturity: isoDay(maturityAt),
items: [
{
name: inputs.itemName,
amount,
unit: inputs.unit ?? 'ks',
unitPrice: inputs.unitPrice,
discountPercentage: 0,
isTaxMovement: false,
priceType: inputs.priceType ?? 0,
vatRateType: inputs.vatRateType ?? 0,
},
],
};
if (inputs.variableSymbol) payload.variableSymbol = inputs.variableSymbol;
if (inputs.note) payload.note = inputs.note;
const { body } = await ctx.http.post('/issued-invoices', payload);
const invoice = unwrap(body);
ctx.log(`Faktura vystavena pro odběratele ${inputs.partnerId}.`);
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
dateOfMaturity:
date(pick(invoice, 'dateOfMaturity')) ?? need(date(maturityAt), 'datum splatnosti'),
};
}
+97
View File
@@ -0,0 +1,97 @@
/**
* iDoklad: dohledani kontaktu podle ICO nebo e-mailu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /contacts?filter=(IdentificationNumber~eq~12345678)
*
* Vzor **vlastni kontroly vstupu**. Manifest umi rict "tohle pole je povinne",
* ale ne "aspon jedno z dvojice". Takova pravidla patri do kodu, protoze jen
* tam jde napsat citelny duvod.
*/
export const manifest = {
id: 'idoklad.find-contact',
name: 'Najít kontakt',
description:
'Dohledá odběratele v iDokladu podle IČO nebo e-mailu. Nic nezakládá. ' +
'Výsledek se použije jako ID odběratele při vystavení faktury.',
inputs: [
{
id: 'identificationNumber',
label: 'IČO',
type: 'string',
required: false,
pattern: '^[0-9]{6,12}$',
hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
},
{
id: 'email',
label: 'E-mail',
type: 'string',
required: false,
hint: 'Použije se, když IČO není k dispozici.',
},
],
outputs: [
{ id: 'found', label: 'Kontakt nalezen', type: 'boolean', required: true },
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: false },
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
{ id: 'identificationNumber', label: 'IČO', type: 'string', required: false },
{ id: 'email', label: 'E-mail', type: 'string', required: false },
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
],
};
const notFound = {
found: false,
contactId: null,
companyName: null,
identificationNumber: null,
email: null,
matchedBy: 'nenalezeno',
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num } = ctx.util;
if (!inputs.identificationNumber && !inputs.email) {
ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat.');
}
/** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
async function search(field, value) {
const { body } = await ctx.http.get('/contacts', {
query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
});
const items = unwrap(body);
return Array.isArray(items) && items.length > 0 ? items[0] : null;
}
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
const attempts = [];
if (inputs.identificationNumber) {
attempts.push(['IdentificationNumber', inputs.identificationNumber, 'IČO']);
}
if (inputs.email) attempts.push(['Email', inputs.email, 'e-mail']);
for (const [field, value, label] of attempts) {
const contact = await search(field, value);
if (!contact) {
ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
continue;
}
return {
found: true,
contactId: num(pick(contact, 'id')),
companyName: text(pick(contact, 'companyName', 'name')),
identificationNumber: text(pick(contact, 'identificationNumber')),
email: text(pick(contact, 'email')),
matchedBy: label,
};
}
return notFound;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* iDoklad: dohledani vydane faktury podle cisla dokladu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /issued-invoices?filter=(DocumentNumber~eq~2024001)
*
* Tohle je vzor **predvalidace**: skript nic nemeni, jen odpovi, jestli doklad
* existuje. Vystup `found` je pak to, na co se ve strome vetvi podminka.
* Stejny princip jako akce "Dohledat firmu" u CRM, viz documentation/06-tickety.md.
*
* Proto taky nenalezena faktura NENI chyba. Kdyby skript spadl, nesla by
* postavit vetev "doklad neznam, zaloz ho".
*/
export const manifest = {
id: 'idoklad.find-issued-invoice',
name: 'Najít vydanou fakturu',
description:
'Zjistí, jestli v iDokladu existuje vydaná faktura s daným číslem dokladu. ' +
'Nic nezakládá ani nemění. Podle výsledku se strom větví.',
inputs: [
{
id: 'documentNumber',
label: 'Číslo dokladu',
type: 'string',
required: true,
hint: 'Číslo, jak je na faktuře, například 2024001.',
},
],
outputs: [
{ id: 'found', label: 'Faktura nalezena', type: 'boolean', required: true },
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: false },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: false },
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: false },
{
id: 'ambiguous',
label: 'Odpovídá víc faktur',
type: 'boolean',
required: true,
},
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, num, bool, date } = ctx.util;
const { body } = await ctx.http.get('/issued-invoices', {
query: {
// Tvar filtru je dany iDokladem: (Pole~operator~hodnota)
filter: `(DocumentNumber~eq~${inputs.documentNumber})`,
filtertype: 'and',
// Dva staci: jeden na vysledek, druhy na zjisteni, ze neni jednoznacny.
pageSize: 2,
},
});
const items = unwrap(body);
const list = Array.isArray(items) ? items : [];
if (list.length === 0) {
ctx.log(`Faktura ${inputs.documentNumber} v iDokladu není.`);
return {
found: false,
invoiceId: null,
totalWithVat: null,
dateOfMaturity: null,
isPaid: null,
ambiguous: false,
};
}
if (list.length > 1) {
// Neni to chyba, ale nekdo to ma vedet - cislo dokladu ma byt jednoznacne.
ctx.log(`Číslu dokladu ${inputs.documentNumber} odpovídá víc faktur, beru první.`);
}
const invoice = list[0];
return {
found: true,
invoiceId: num(pick(invoice, 'id')),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
dateOfMaturity: date(pick(invoice, 'dateOfMaturity')),
isPaid: bool(pick(invoice, 'isPaid')),
ambiguous: list.length > 1,
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* iDoklad: nacteni vydane faktury podle ID.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /issued-invoices/{id}
*
* Nejjednodussi tvar skriptu: jedno volani a prevod odpovedi na vystupy.
*
* iDoklad vraci pole s velkym pocatecnim pismenem a nekdy obaluje odpoved
* do `Data`. Proto `unwrap` a `pick` - nespoléhá se na presny tvar odpovedi,
* protoze ten se u cizich sluzeb meni bez ohlaseni.
*/
export const manifest = {
id: 'idoklad.get-issued-invoice',
name: 'Získat vydanou fakturu',
description:
'Načte vydanou fakturu z iDokladu podle jejího ID. Používá se před rozhodnutím, ' +
'co s ní dál, například jestli je už uhrazená.',
inputs: [
{
id: 'invoiceId',
label: 'ID faktury v iDokladu',
type: 'number',
required: true,
hint: 'Interní ID, ne číslo dokladu. Číslo dokladu umí dohledat akce Najít vydanou fakturu.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
{ id: 'partnerId', label: 'ID odběratele', type: 'number', required: false },
{ id: 'partnerName', label: 'Odběratel', type: 'string', required: false },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: true },
{ id: 'currencyId', label: 'ID měny', type: 'number', required: false },
{ id: 'dateOfIssue', label: 'Datum vystavení', type: 'date', required: true },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, bool, date, need } = ctx.util;
const { body } = await ctx.http.get(`/issued-invoices/${inputs.invoiceId}`);
const invoice = unwrap(body);
if (!invoice || typeof invoice !== 'object') {
ctx.fail(`Faktura ${inputs.invoiceId} v iDokladu neexistuje.`);
}
// Odberatel muze byt jak plocha hodnota, tak vnoreny objekt partnera.
const partner = pick(invoice, 'partner', 'customer');
const partnerName =
text(pick(invoice, 'partnerName', 'customerName')) ??
text(pick(partner, 'companyName', 'name'));
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
variableSymbol: text(pick(invoice, 'variableSymbol')),
partnerId: num(pick(invoice, 'partnerId')) ?? num(pick(partner, 'id')),
partnerName,
totalWithVat: need(
num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
'celkovou částku',
),
currencyId: num(pick(invoice, 'currencyId')),
dateOfIssue: need(date(pick(invoice, 'dateOfIssue')), 'datum vystavení'),
dateOfMaturity: need(date(pick(invoice, 'dateOfMaturity')), 'datum splatnosti'),
isPaid: bool(pick(invoice, 'isPaid')),
};
}
+98
View File
@@ -0,0 +1,98 @@
/**
* iDoklad: zapsani uhrady k vydane fakture.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-payments/default/{invoiceId}, POST /issued-payments
*
* Vzor akce, ktera **neco meni**. U te zalezi na idempotenci: kdyz runtime krok
* zopakuje po timeoutu, nesmi vzniknout druha uhrada. Klic `ctx.idempotencyKey`
* je pro tentyz krok stejny pres vsechny pokusy a runtime ho posila v hlavicce
* `Idempotency-Key` automaticky, takze skript nemusi delat nic navic.
*
* Castka se necha prazdna pro plnou uhradu - vzor z iDokladu uz nese zbytek
* k zaplaceni, takze se nemusi pocitat tady.
*/
export const manifest = {
id: 'idoklad.register-payment',
name: 'Zapsat úhradu faktury',
description:
'Zapíše k vydané faktuře úhradu. Bez zadané částky se použije zbytek ' +
'k zaplacení podle iDokladu.',
inputs: [
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
{
id: 'amount',
label: 'Uhrazená částka',
type: 'number',
required: false,
hint: 'Nevyplněno = celý zbytek k zaplacení.',
},
{
id: 'dateOfPayment',
label: 'Datum úhrady',
type: 'date',
required: false,
hint: 'Nevyplněno = dnes.',
},
{
id: 'sendConfirmation',
label: 'Poslat potvrzení odběrateli',
type: 'boolean',
required: false,
default: false,
},
],
outputs: [
{ id: 'paymentId', label: 'ID úhrady', type: 'number', required: true },
{ id: 'amount', label: 'Zapsaná částka', type: 'number', required: true },
{ id: 'dateOfPayment', label: 'Datum úhrady', type: 'date', required: true },
],
};
function isoDay(value) {
return new Date(value).toISOString().slice(0, 10);
}
export async function run(inputs, ctx) {
const { unwrap, pick, num, date, need } = ctx.util;
if (inputs.amount !== null && inputs.amount <= 0) {
ctx.fail('Uhrazená částka musí být větší než nula.');
}
// Vzor nese zbytek k zaplaceni i vychozi zpusob platby.
const defaults = unwrap((await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body);
if (!defaults || typeof defaults !== 'object') {
ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`);
}
const suggested = num(pick(defaults, 'paymentAmount'));
const amount = inputs.amount ?? suggested;
if (amount === null) {
ctx.fail('iDoklad nevrátil zbytek k zaplacení, zadejte částku ručně.');
}
const paidAt = inputs.dateOfPayment ? new Date(inputs.dateOfPayment) : new Date();
const payload = {
...defaults,
invoiceId: inputs.invoiceId,
paymentAmount: amount,
dateOfPayment: isoDay(paidAt),
sendPaymentConfirmation: inputs.sendConfirmation ?? false,
};
const { body } = await ctx.http.post('/issued-payments', payload);
const payment = unwrap(body);
ctx.log(`K faktuře ${inputs.invoiceId} zapsána úhrada ${amount}.`);
return {
paymentId: need(num(pick(payment, 'id')), 'ID úhrady'),
amount: need(num(pick(payment, 'paymentAmount')) ?? amount, 'zapsanou částku'),
dateOfPayment: need(date(pick(payment, 'dateOfPayment')) ?? date(paidAt), 'datum úhrady'),
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* iDoklad: odeslani vydane faktury e-mailem.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: POST /mail/issued-invoices/send
*
* Vzor akce, u ktere **odpoved sluzby nic nevraci**. Vystup se proto sklada
* z toho, co skript posilal, ne z toho, co prislo zpatky. Bez toho by strom
* za timhle krokem nemel na cem stavet podminku.
*
* Zaroven je to vzor toho, jak nahradit jeden vstup dvema chovanimi: kdyz je
* vyplneny e-mail, posle se na nej. Kdyz neni, posle se na adresu odberatele
* vedenou v iDokladu.
*/
export const manifest = {
id: 'idoklad.send-invoice-email',
name: 'Odeslat fakturu e-mailem',
description:
'Odešle vydanou fakturu e-mailem. Bez zadané adresy jde na e-mail ' +
'odběratele vedený v iDokladu.',
timeoutMs: 30000,
inputs: [
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
{
id: 'email',
label: 'E-mail příjemce',
type: 'string',
required: false,
pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
hint: 'Nevyplněno = adresa odběratele z iDokladu.',
},
{ id: 'subject', label: 'Předmět', type: 'string', required: false },
{ id: 'body', label: 'Text e-mailu', type: 'string', required: false, multiline: true },
{
id: 'sendAttachment',
label: 'Přiložit PDF faktury',
type: 'boolean',
required: false,
default: true,
},
{
id: 'sendToSelf',
label: 'Poslat kopii sobě',
type: 'boolean',
required: false,
default: false,
},
],
outputs: [
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
{ id: 'recipient', label: 'Komu se odeslalo', type: 'string', required: true },
],
};
export async function run(inputs, ctx) {
const toGivenAddress = Boolean(inputs.email);
const payload = {
documentId: inputs.invoiceId,
// Vsechna tri pole jsou u iDokladu povinna, i kdyz jsou nepravdiva.
sendToPartner: !toGivenAddress,
sendToAccountant: false,
sendToSelf: inputs.sendToSelf ?? false,
sendAttachment: inputs.sendAttachment ?? true,
...(toGivenAddress ? { otherRecipients: [inputs.email] } : {}),
...(inputs.subject ? { emailSubject: inputs.subject } : {}),
...(inputs.body ? { emailBody: inputs.body } : {}),
};
// Nektere instance vraci 204 bez tela, jine 200 s potvrzenim. Obojí je uspech.
const { status } = await ctx.http.post('/mail/issued-invoices/send', payload);
const recipient = toGivenAddress ? String(inputs.email) : 'odběratel z iDokladu';
ctx.log(`Faktura ${inputs.invoiceId} odeslána (${recipient}), HTTP ${status}.`);
return { sent: true, recipient };
}
+31
View File
@@ -8,9 +8,15 @@
*/
import { randomBytes } from 'node:crypto';
import path from 'node:path';
const isProduction = process.env.NODE_ENV === 'production';
function positiveNumber(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* Tajny klic pro podpis tokenu.
*
@@ -68,6 +74,31 @@ export const config = {
* relativni tvar - nikdy se nehardcoduje produkcni domena.
*/
publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''),
// ------------------------------------------------------- skripty konektoru
/**
* Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
* bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
*/
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
/**
* Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
* Konkretni konektor lze presmerovat pres `<KONEKTOR>_BASE_URL`.
* Nikdy se nehardcoduje do logiky, viz AGENTS.md.
*/
servicesBaseUrl: (process.env.SERVICES_BASE_URL ?? 'https://services.csbot.cz/apps')
.trim()
.replace(/\/+$/, ''),
/** Strop na jeden beh skriptu, kdyz si ho manifest neurci sam. */
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Povoli skriptum volat na localhost a do privatnich rozsahu IP.
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
*/
allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
};
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
+60 -1
View File
@@ -89,6 +89,14 @@ export interface ConnectorOperation {
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
*/
providedFields?: ProvidedField[];
/**
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
* Doplnuje `src/scripts/catalog.ts`, rucne se to nepise.
*/
implementation?: 'script';
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
scriptId?: string;
}
export interface Connector {
@@ -1097,6 +1105,57 @@ export function findConnector(connectorId: string): Connector | undefined {
return connectors.find((c) => c.id === connectorId);
}
// ------------------------------------------------------- akce ze skriptu
/**
* Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
* pri kazdem nacteni skriptu.
*
* Je to prekryv, ne zapis do `connectors`. Dva duvody: staticky katalog
* zustane citelny a z operace jde poznat, odkud je (`implementation`).
*
* Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
* uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
* stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
*/
const scriptActions = new Map<string, ConnectorOperation[]>();
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): void {
scriptActions.clear();
for (const [connectorId, operations] of byConnector) {
scriptActions.set(connectorId, operations);
}
}
/**
* Akce konektoru vcetne tech ze skriptu.
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
* Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
*/
export function actionsFor(connectorId: string): ConnectorOperation[] {
const connector = findConnector(connectorId);
if (!connector) return [];
const fromScripts = scriptActions.get(connectorId);
if (!fromScripts || fromScripts.length === 0) return connector.actions;
const replaced = new Set(fromScripts.map((operation) => operation.id));
return [
...connector.actions.filter((action) => !replaced.has(action.id)),
...fromScripts,
].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
/** Katalog pro portal. Nemodifikuje `connectors`, sklada nove objekty. */
export function connectorCatalog(): Connector[] {
return connectors.map((connector) =>
scriptActions.has(connector.id)
? { ...connector, actions: actionsFor(connector.id) }
: connector,
);
}
/**
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
@@ -1108,7 +1167,7 @@ export function findOperation(
): ConnectorOperation | undefined {
const connector = findConnector(connectorId);
if (!connector) return undefined;
const pool = type === 'trigger' ? connector.triggers : connector.actions;
const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
return pool.find((op) => op.id === operationId);
}
+9
View File
@@ -16,6 +16,7 @@ import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard.js';
import { simulateRouter } from './routes/simulate.js';
import { webhookRouter } from './routes/webhook.js';
import { ensureLoaded, scriptsDir } from './scripts/registry.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
@@ -168,11 +169,19 @@ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
});
});
/**
* Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji
* katalog a bez nich by prvni ulozeni stromu neznalo operace ze skriptu.
* `ensureLoaded` chyby polyka a loguje, takze start nemuze shodit (AGENTS.md).
*/
await ensureLoaded(true);
// Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md).
const server = app.listen(config.port, '0.0.0.0', () => {
console.info(`[start] csbot-prototype bezi na portu ${config.port}`);
console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`);
console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`);
console.info(`[start] skripty konektoru: ${scriptsDir()}`);
});
server.on('error', (err: NodeJS.ErrnoException) => {
+306
View File
@@ -26,6 +26,7 @@ export function buildOpenApiDocument() {
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
{ name: 'Skripty', description: 'Vykonna cast konektoru: manifest, kod a zkusebni beh' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
@@ -98,6 +99,145 @@ export function buildOpenApiDocument() {
personId: { type: 'string', nullable: true },
},
},
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar konektor.operace. Nazev souboru musi byt <id>.js.',
},
connectorId: { type: 'string', example: 'idoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description:
'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
LoginRequest: {
type: 'object',
required: ['email', 'password'],
@@ -811,10 +951,176 @@ export function buildOpenApiDocument() {
get: {
tags: ['Automatizace'],
summary: 'Katalog konektoru',
description:
'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
},
},
'/api/dashboard/scripts': {
get: {
tags: ['Skripty'],
summary: 'Seznam skriptu konektoru',
description:
'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' +
'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' +
'jen jmena chybejicich environment variables.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Skripty, problemy a stav napojeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptManifest' },
},
problems: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptProblem' },
},
connections: {
type: 'array',
items: { $ref: '#/components/schemas/ConnectionStatus' },
},
directory: { type: 'string', example: '/app/scripts' },
},
},
},
},
},
},
},
},
'/api/dashboard/scripts/reload': {
post: {
tags: ['Skripty'],
summary: 'Znovu nacist skripty ze slozky',
description:
'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' +
'to jen vynuti hned, bez cekani.',
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Skripty po nacteni' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}': {
get: {
tags: ['Skripty'],
summary: 'Manifest a kod skriptu',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
responses: {
'200': {
description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.',
},
'400': { description: 'Neplatne ID skriptu' },
'404': { description: 'Skript neexistuje' },
},
},
put: {
tags: ['Skripty'],
summary: 'Ulozit kod skriptu',
description:
'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' +
'se neulozi a puvodni skript dal funguje.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['code'],
properties: {
code: {
type: 'string',
description: 'Cely obsah souboru vcetne exportu manifest a run.',
},
},
},
},
},
},
responses: {
'200': { description: 'Ulozeno, vraci se overeny manifest' },
'400': {
description: 'Kod nebo manifest neprosel, v `issues` je co opravit',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}/test': {
post: {
tags: ['Skripty'],
summary: 'Zkusebni spusteni skriptu',
description:
'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' +
'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
inputs: {
type: 'object',
additionalProperties: true,
example: { invoiceId: 12345 },
},
},
},
},
},
},
responses: {
'200': {
description: 'Vysledek behu',
content: {
'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } },
},
},
'400': { description: 'Neplatne ID nebo vstupy' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/automations': {
get: {
tags: ['Automatizace'],
+11 -2
View File
@@ -18,8 +18,8 @@ import {
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import {
connectorCatalog,
connectorCategories,
connectors,
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
@@ -47,6 +47,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
export const dashboardRouter = Router();
@@ -341,12 +342,20 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
// Skripty konektoru. Taky pred obecnymi cestami.
dashboardRouter.use('/scripts', scriptsRouter);
// ---------------------------------------------------------------- konektory
/**
* Katalog uz neni jen staticky seznam. Operace, ktere obsluhuje skript, se
* domeruji z jeho manifestu, takze builder vidi skutecne vstupy a vystupy.
* Podrobnosti v `src/scripts/catalog.ts`.
*/
dashboardRouter.get('/connectors', (_req, res) => {
res.json({
categories: connectorCategories,
items: connectors,
items: connectorCatalog(),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
+145
View File
@@ -0,0 +1,145 @@
/**
* Sprava skriptu konektoru z portalu.
*
* Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi.
* Uprava a spusteni smi jen spravce platformy. Uprava skriptu meni chovani
* vseho, co ho pouziva, takze to neni pravo, ktere se dava vedle prava
* zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
*/
import { Router } from 'express';
import { z } from 'zod';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
import {
ensureLoaded,
getScript,
isValidScriptId,
listManifests,
readSource,
saveSource,
scriptProblems,
scriptsDir,
} from '../scripts/registry.js';
import { runScript } from '../scripts/runner.js';
export const scriptsRouter = Router();
/** Manifest plus to, co si klient nema dopocitavat sam. */
async function scriptSummaries() {
const manifests = await listManifests();
return manifests.map((manifest) => ({
...manifest,
connectorId: connectorIdOf(manifest.id),
operationId: operationIdOf(manifest.id),
}));
}
scriptsRouter.get('/', async (_req, res) => {
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
res.json({
items,
problems,
connections: connectorsWithAuth().map(connectionStatus),
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
directory: scriptsDir(),
});
});
scriptsRouter.post('/reload', requirePlatformAdmin, async (_req, res) => {
await ensureLoaded(true);
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
res.json({ items, problems });
});
scriptsRouter.get('/:id', async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
}
const [script, source] = await Promise.all([getScript(id), readSource(id)]);
if (source === null) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
const problems = await scriptProblems();
return res.json({
id,
connectorId: connectorIdOf(id),
operationId: operationIdOf(id),
manifest: script?.manifest ?? null,
code: source,
problem: problems.find((problem) => problem.scriptId === id) ?? null,
connection: connectionStatus(connectorIdOf(id)),
});
});
const saveSchema = z.object({
code: z.string().min(1, 'Kód skriptu nesmí být prázdný.'),
});
scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({
error: 'validation_error',
message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
});
}
const parsed = saveSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
const result = await saveSource(id, parsed.data.code);
if (!result.ok) {
// Rozbita uprava se neulozi a puvodni skript dal funguje.
return res.status(400).json({
error: 'validation_error',
message: result.message,
issues: result.issues ?? [],
});
}
return res.json({ id, manifest: result.manifest });
});
const testSchema = z.object({
inputs: z.record(z.unknown()).default({}),
});
/**
* Zkusebni spusteni.
*
* Vola opravdovou sluzbu, tedy vystavena faktura opravdu vznikne. Zamerne:
* test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
* Portal na to upozorni pred stiskem.
*/
scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
}
const parsed = testSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Vstupy musí být objekt s hodnotami parametrů.',
});
}
const result = await runScript(id, parsed.data.inputs);
console.info(`[scripts] test ${id} uzivatelem ${req.user!.email}: ${result.ok ? 'ok' : 'chyba'}`);
// Chyba skriptu neni chyba API. Vysledek se vraci vzdy s 200 vcetne popisu.
return res.json(result);
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Napojeni konektoru: kam se vola a cim se to autorizuje.
*
* Zamerne oddelene od skriptu. Skript rika "GET /issued-invoices/12",
* napojeni rika, na jake adrese to je a jaké hlavicky se pridaji. Skript se
* tim k pristupovym udajum vubec nedostane.
*
* Tady je zatim jedno napojeni na konektor, sestavene z environment variables.
* Cilovy stav je napojeni za firmu v databazi, viz documentation/09, bod 9.
* Az to bude, prepise se vnitrek `resolveConnection` a nic dalsiho.
*/
import { config } from '../config.js';
export interface ConnectorAuthSpec {
/** Hlavicka -> jmeno environment variable, ze ktere se plni. */
headers: Record<string, string>;
/** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
required: string[];
/** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
config?: Record<string, string>;
}
/**
* Autorizace jednotlivych konektoru.
*
* iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima
* `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace.
* OAuth tok resi ta sluzba, my posilame jen tyto hlavicky.
*/
const authSpecs: Record<string, ConnectorAuthSpec> = {
idoklad: {
headers: {
'X-ClientId': 'IDOKLAD_CLIENT_ID',
'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET',
'X-ApplicationId': 'IDOKLAD_APPLICATION_ID',
},
required: ['X-ClientId', 'X-ClientSecret'],
config: { language: 'IDOKLAD_LANGUAGE' },
},
};
export interface ResolvedConnection {
connectorId: string;
name: string;
baseUrl: string;
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
headers: Record<string, string>;
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
config: Record<string, string>;
/** false = chybi pristupove udaje, volat nema smysl. */
ready: boolean;
/** Jmena environment variables, ktere chybi. */
missing: string[];
}
/** `search-console` -> `SEARCH_CONSOLE`, aby slo skladat jmena promennych. */
function envPrefix(connectorId: string): string {
return connectorId.replace(/-/g, '_').toUpperCase();
}
function readEnv(name: string): string | undefined {
const value = process.env[name];
return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
}
/**
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
* bere se z `SERVICES_BASE_URL` (viz AGENTS.md).
* Jednotlive konektory lze presmerovat pres `<KONEKTOR>_BASE_URL`.
*/
function resolveBaseUrl(connectorId: string): string {
return (
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
);
}
export function resolveConnection(connectorId: string): ResolvedConnection {
const spec = authSpecs[connectorId];
const headers: Record<string, string> = {};
const scriptConfig: Record<string, string> = {};
const missing: string[] = [];
for (const [header, envName] of Object.entries(spec?.headers ?? {})) {
const value = readEnv(envName);
if (value !== undefined) headers[header] = value;
else if (spec?.required.includes(header)) missing.push(envName);
}
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
const value = readEnv(envName);
if (value !== undefined) scriptConfig[key] = value;
}
return {
connectorId,
name: `${connectorId} (z environment variables)`,
baseUrl: resolveBaseUrl(connectorId),
headers,
config: scriptConfig,
ready: missing.length === 0,
missing,
};
}
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
export function connectionSecrets(connection: ResolvedConnection): string[] {
return Object.values(connection.headers);
}
export interface ConnectionStatus {
connectorId: string;
baseUrl: string;
ready: boolean;
/** Jen jmena chybejicich promennych, nikdy hodnoty. */
missing: string[];
/** Ktere hlavicky jsou vyplnene. Hodnoty se nevraci. */
headers: string[];
}
/**
* Stav napojeni pro portal. Vraci se **jen jmena**, nikdy hodnoty -
* secrets se z beznych endpointu nevraci (AGENTS.md).
*/
export function connectionStatus(connectorId: string): ConnectionStatus {
const connection = resolveConnection(connectorId);
return {
connectorId,
baseUrl: connection.baseUrl,
ready: connection.ready,
missing: connection.missing,
headers: Object.keys(connection.headers),
};
}
/** Ktere konektory maji popsanou autorizaci. */
export function connectorsWithAuth(): string[] {
return Object.keys(authSpecs);
}
+201
View File
@@ -0,0 +1,201 @@
/**
* HTTP klient, ktery dostane skript jako `ctx.http`.
*
* Delá ctyri veci, ktere by jinak resil kazdy skript znovu a spatne:
* - sklada adresu z napojeni, takze skript zna jen cestu,
* - pridava autorizacni hlavicky, takze skript nezna tajemstvi,
* - rozlisuje opakovatelnou chybu od koncove,
* - loguje volani bez hlavicek a bez tel, jen metodu, cestu a kod.
*/
import { config } from '../config.js';
import type { ResolvedConnection } from './connections.js';
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
import { describe } from './util.js';
/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
/** Chyby spojeni od Node. Vsechny jsou docasne. */
const retryableCodes = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'EAI_AGAIN',
'EPIPE',
'ENOTFOUND',
'UND_ERR_SOCKET',
'UND_ERR_CONNECT_TIMEOUT',
]);
const privateHostPattern =
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
/**
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
*/
function assertAllowedUrl(url: URL): void {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
throw new ScriptError(
'config',
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
);
}
}
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
let url: URL;
try {
url = new URL(joinUrl(connection.baseUrl, path));
} catch {
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
}
for (const [key, value] of Object.entries(options?.query ?? {})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
assertAllowedUrl(url);
return url;
}
function statusError(status: number, url: URL, detail: string): ScriptError {
const where = `${url.pathname} vrátilo HTTP ${status}`;
if (retryableStatuses.has(status)) {
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, {
status,
detail,
});
}
if (status === 401 || status === 403) {
return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, {
status,
detail,
});
}
if (status === 404) {
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail });
}
return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail });
}
function transportError(err: unknown, url: URL): ScriptError {
if (err instanceof ScriptError) return err;
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const message = err instanceof Error ? err.message : String(err);
if (name === 'AbortError' || name === 'TimeoutError') {
return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err });
}
if (retryableCodes.has(code)) {
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err });
}
return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err });
}
export interface CreateHttpOptions {
connection: ResolvedConnection;
signal: AbortSignal;
idempotencyKey: string;
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
redact: (value: string) => string;
log: (message: string, detail?: unknown) => void;
onCall: () => void;
}
export function createHttp(options: CreateHttpOptions): ScriptHttp {
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
async function request<T>(
method: string,
path: string,
body: unknown,
httpOptions?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>> {
const url = buildUrl(connection, path, httpOptions);
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
let response: Response;
try {
response = await fetch(url, {
method,
signal,
headers: {
Accept: 'application/json',
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
'Idempotency-Key': idempotencyKey,
...connection.headers,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...httpOptions?.headers,
},
body: hasBody ? JSON.stringify(body) : undefined,
});
} catch (err) {
throw transportError(err, url);
}
const declaredSize = Number(response.headers.get('content-length') ?? 0);
if (declaredSize > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
}
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
let parsed: unknown = raw;
if (isJson && raw.length > 0) {
try {
parsed = JSON.parse(raw);
} catch {
throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
status: response.status,
detail: redact(describe(raw, 300)),
});
}
}
log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`);
const allowed = httpOptions?.allowStatus ?? [];
if (!response.ok && !allowed.includes(response.status)) {
throw statusError(response.status, url, redact(describe(parsed, 400)));
}
return { status: response.status, body: parsed as T };
}
return {
get: (path, httpOptions) => request('GET', path, undefined, httpOptions),
post: (path, body, httpOptions) => request('POST', path, body, httpOptions),
patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
};
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Prevody kolem manifestu skriptu.
*
* Skript je zdroj pravdy o svych parametrech. Katalog konektoru z nich jen
* odvozuje to, co potrebuje builder. Kdyby se pole psala na dvou mistech,
* jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna.
*/
import type { ConnectorOperation, ProvidedField } from '../data/connectors.js';
import type { OperationField } from '../data/connectors.js';
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
/** `idoklad.get-issued-invoice` -> `idoklad` */
export function connectorIdOf(scriptId: string): string {
return scriptId.slice(0, scriptId.indexOf('.'));
}
/** `idoklad.get-issued-invoice` -> `get-issued-invoice` */
export function operationIdOf(scriptId: string): string {
return scriptId.slice(scriptId.indexOf('.') + 1);
}
export type ParseResult =
| { ok: true; manifest: ScriptManifest }
| { ok: false; issues: FieldIssue[] };
/**
* Overi manifest a zaroven to, ze odpovida nazvu souboru.
* Nesoulad nazvu je chyba, ne varovani - jinak by se skript ulozil pod jednim
* jmenem a nacetl pod druhym.
*/
export function parseManifest(raw: unknown, expectedId: string): ParseResult {
const parsed = scriptManifestSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.') || 'manifest',
message: issue.message,
})),
};
}
if (parsed.data.id !== expectedId) {
return {
ok: false,
issues: [
{
field: 'id',
message: `Manifest má id "${parsed.data.id}", ale soubor se jmenuje "${expectedId}.js". Musí být stejné.`,
},
],
};
}
return { ok: true, manifest: parsed.data };
}
/**
* Nastavitelne pole akce pro builder.
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
*/
function toOperationField(field: ScriptField): OperationField {
return {
id: field.id,
label: field.label,
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
required: field.required,
...(field.options ? { options: field.options } : {}),
...(field.hint ? { hint: field.hint } : {}),
};
}
/**
* Vystup kroku pro strom.
*
* `id` nese prefix konektoru, protoze se na nej odkazuji podminky v ulozenych
* stromech a musi byt jednoznacne. `name` je to, co se pise do sablony -
* stejne rozdeleni jako u ostatnich konektoru, viz documentation/06-tickety.md.
*/
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
return {
id: `${connectorIdOf(scriptId)}.${field.id}`,
name: field.id,
type: field.type,
required: field.required,
};
}
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
return {
id: operationIdOf(manifest.id),
name: manifest.name,
description: manifest.description,
inputs: manifest.inputs.map(toOperationField),
outputFields: manifest.outputs.map((field) => toProvidedField(manifest.id, field)),
implementation: 'script',
scriptId: manifest.id,
};
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Nacitani skriptu ze souboru.
*
* Myslenka: skript jde vytvorit nebo upravit v rozhrani i rucne v souboru
* a **nic se kvuli tomu neotaci**. Registr proto sleduje cas zmeny souboru
* a pri zmene ho nacte znovu. Nazev souboru je `<id>.js`, takze mezi souborem
* a operaci v katalogu neni zadna mapa, ktera by mohla lhat.
*
* Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
* musel prelozit, a to je presne to otaceni, ktere tady nema byt.
*
* Rozbity skript nesmi shodit ostatni. Zapise se do `problems()` a portal ho
* ukaze - zadna ticha selhani.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { config } from '../config.js';
import { connectors, setScriptActions, type ConnectorOperation } from '../data/connectors.js';
import { connectorIdOf, parseManifest, toConnectorOperation } from './manifest.js';
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
export type ScriptRunFn = (
inputs: ScriptValues,
ctx: ScriptContext,
) => Promise<unknown> | unknown;
export interface LoadedScript {
manifest: ScriptManifest;
run: ScriptRunFn;
file: string;
mtimeMs: number;
}
export interface ScriptProblem {
/** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */
file: string;
scriptId: string | null;
message: string;
issues?: FieldIssue[];
}
/** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */
const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
const scripts = new Map<string, LoadedScript>();
const problems = new Map<string, ScriptProblem>();
/** Nez se znovu prohleda adresar. Bez toho by se stat volal pri kazdem dotazu. */
const RESCAN_MS = 1000;
let lastScanAt = 0;
let scanning: Promise<void> | null = null;
export function scriptsDir(): string {
return config.scriptsDir;
}
export function isValidScriptId(id: string): boolean {
return idPattern.test(id);
}
function fileFor(id: string): string {
if (!isValidScriptId(id)) throw new Error(`Neplatné ID skriptu: ${id}`);
return path.join(scriptsDir(), `${id}.js`);
}
function idFor(fileName: string): string {
return fileName.replace(/\.js$/, '');
}
function problemMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
/**
* Nacte jeden soubor. Query `?v=` je nutna - bez ni si Node drzi prvni verzi
* modulu v cache a uprava souboru by se nikdy neprojevila.
*/
async function importScript(
file: string,
mtimeMs: number,
expectedId: string,
): Promise<LoadedScript | ScriptProblem> {
const fileName = path.basename(file);
const url = `${pathToFileURL(file).href}?v=${mtimeMs}`;
let module: { manifest?: unknown; run?: unknown };
try {
module = (await import(url)) as { manifest?: unknown; run?: unknown };
} catch (err) {
return { file: fileName, scriptId: expectedId, message: `Soubor se nepodařilo načíst: ${problemMessage(err)}` };
}
if (typeof module.run !== 'function') {
return {
file: fileName,
scriptId: expectedId,
message: 'Soubor musí exportovat funkci run(inputs, ctx).',
};
}
const parsed = parseManifest(module.manifest, expectedId);
if (!parsed.ok) {
return {
file: fileName,
scriptId: expectedId,
message: 'Manifest není platný.',
issues: parsed.issues,
};
}
return {
manifest: parsed.manifest,
run: module.run as ScriptRunFn,
file: fileName,
mtimeMs,
};
}
function isProblem(value: LoadedScript | ScriptProblem): value is ScriptProblem {
return 'message' in value;
}
async function scan(): Promise<void> {
const dir = scriptsDir();
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch (err) {
// Chybejici adresar neni chyba aplikace, jen nejsou zadne skripty.
console.warn(`[scripts] adresar ${dir} nelze precist: ${problemMessage(err)}`);
scripts.clear();
problems.clear();
return;
}
// Soubory od podtrzitka jsou pomocne, nejsou to skripty.
const files = entries.filter((name) => name.endsWith('.js') && !name.startsWith('_'));
const seen = new Set<string>();
for (const fileName of files) {
const id = idFor(fileName);
seen.add(id);
const file = path.join(dir, fileName);
if (!isValidScriptId(id)) {
problems.set(id, {
file: fileName,
scriptId: null,
message: 'Název souboru musí mít tvar konektor.operace.js, jen malá písmena a pomlčky.',
});
continue;
}
let mtimeMs: number;
try {
mtimeMs = (await fs.stat(file)).mtimeMs;
} catch (err) {
problems.set(id, { file: fileName, scriptId: id, message: problemMessage(err) });
continue;
}
const cached = scripts.get(id);
if (cached && cached.mtimeMs === mtimeMs) {
problems.delete(id);
continue;
}
const loaded = await importScript(file, mtimeMs, id);
if (isProblem(loaded)) {
// Rozbita uprava nesmi zahodit posledni funkcni verzi v pameti.
problems.set(id, loaded);
console.error(`[scripts] ${fileName}: ${loaded.message}`);
continue;
}
scripts.set(id, loaded);
problems.delete(id);
console.info(`[scripts] nacten ${id} (${loaded.manifest.name})`);
}
for (const id of [...scripts.keys()]) {
if (!seen.has(id)) {
scripts.delete(id);
console.info(`[scripts] ${id} zmizel z adresare`);
}
}
for (const id of [...problems.keys()]) {
if (!seen.has(id)) problems.delete(id);
}
publishToCatalog();
}
/**
* Prenese nactene skripty do katalogu konektoru.
*
* Tim se naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku
* videt, i v sablonach - vsechno se uz pta pres `findOperation`.
*/
function publishToCatalog(): void {
const byConnector = new Map<string, ConnectorOperation[]>();
for (const script of scripts.values()) {
const connectorId = connectorIdOf(script.manifest.id);
if (!connectors.some((connector) => connector.id === connectorId)) {
problems.set(script.manifest.id, {
file: script.file,
scriptId: script.manifest.id,
message: `Konektor ${connectorId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
});
continue;
}
const list = byConnector.get(connectorId) ?? [];
list.push(toConnectorOperation(script.manifest));
byConnector.set(connectorId, list);
}
setScriptActions(byConnector);
}
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
export async function ensureLoaded(force = false): Promise<void> {
if (!force && Date.now() - lastScanAt < RESCAN_MS) return;
if (scanning) return scanning;
scanning = scan()
.catch((err: unknown) => {
console.error('[scripts] nacitani selhalo:', err);
})
.finally(() => {
lastScanAt = Date.now();
scanning = null;
});
return scanning;
}
export async function listScripts(): Promise<LoadedScript[]> {
await ensureLoaded();
return [...scripts.values()].sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
}
export async function listManifests(): Promise<ScriptManifest[]> {
return (await listScripts()).map((script) => script.manifest);
}
export async function getScript(id: string): Promise<LoadedScript | undefined> {
await ensureLoaded();
return scripts.get(id);
}
export async function scriptProblems(): Promise<ScriptProblem[]> {
await ensureLoaded();
return [...problems.values()].sort((a, b) => a.file.localeCompare(b.file));
}
export async function readSource(id: string): Promise<string | null> {
if (!isValidScriptId(id)) return null;
try {
return await fs.readFile(fileFor(id), 'utf8');
} catch {
return null;
}
}
export type SaveResult =
| { ok: true; manifest: ScriptManifest }
| { ok: false; message: string; issues?: FieldIssue[] };
/**
* Ulozi kod skriptu.
*
* Poradi je zamerne: nejdriv se zapise do docasneho souboru, ten se nacte
* a overi, a az pak prepise puvodni. Rozbita uprava tim nikdy neshodi
* skript, ktery fungoval.
*/
export async function saveSource(id: string, code: string): Promise<SaveResult> {
if (!isValidScriptId(id)) {
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.' };
}
if (code.trim().length === 0) {
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
}
const target = fileFor(id);
// Cas v nazvu, aby si Node nenacetl predchozi pokus z cache.
const temp = path.join(scriptsDir(), `_tmp.${id}.${Date.now()}.js`);
try {
await fs.mkdir(scriptsDir(), { recursive: true });
await fs.writeFile(temp, code, 'utf8');
const mtimeMs = (await fs.stat(temp)).mtimeMs;
const loaded = await importScript(temp, mtimeMs, id);
if (isProblem(loaded)) {
return { ok: false, message: loaded.message, issues: loaded.issues };
}
await fs.rename(temp, target);
// Nova mtime, at si registr vezme skutecny soubor a ne docasny.
const finalMtime = (await fs.stat(target)).mtimeMs;
scripts.set(id, { ...loaded, file: `${id}.js`, mtimeMs: finalMtime });
problems.delete(id);
publishToCatalog();
console.info(`[scripts] ulozen ${id}`);
return { ok: true, manifest: loaded.manifest };
} catch (err) {
return { ok: false, message: `Uložení selhalo: ${problemMessage(err)}` };
} finally {
await fs.rm(temp, { force: true }).catch(() => undefined);
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Spusteni jednoho skriptu.
*
* Runner nikdy nevyhodi vyjimku. Vzdy vrati vysledek, ve kterem je bud vystup,
* nebo popsana chyba vcetne toho, jestli ma smysl zkusit to znovu. Az bude
* existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku,
* takze fronta nemusi resit nic z toho, co je tady.
*
* Poradi je vzdy stejne: overit vstup, spustit, overit vystup. Neoverený
* vystup by znamenal, ze strom veri parametru, ktery neexistuje.
*/
import { createHash } from 'node:crypto';
import { config } from '../config.js';
import { connectionSecrets, resolveConnection } from './connections.js';
import { createHttp } from './http.js';
import { connectorIdOf } from './manifest.js';
import { getScript } from './registry.js';
import {
isRetryableKind,
ScriptError,
type ScriptContext,
type ScriptErrorKind,
type ScriptLogEntry,
type ScriptRunResult,
} from './types.js';
import { createRedactor, describe, scriptUtil } from './util.js';
import { validateValues } from './values.js';
export interface RunScriptOptions {
/**
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
*/
idempotencyKey?: string;
timeoutMs?: number;
}
function defaultIdempotencyKey(scriptId: string, inputs: unknown): string {
const hash = createHash('sha256')
.update(scriptId)
.update(JSON.stringify(inputs) ?? '')
.digest('base64url');
return `${scriptId}:${hash.slice(0, 24)}`;
}
function toRunError(
err: unknown,
redact: (value: string) => string,
): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } {
if (err instanceof ScriptError) {
return {
kind: err.kind,
message: redact(err.message),
...(err.status !== undefined ? { status: err.status } : {}),
...(err.detail !== undefined ? { detail: redact(err.detail) } : {}),
};
}
const message = err instanceof Error ? err.message : String(err);
// Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi.
return { kind: 'internal', message: redact(`Skript selhal: ${message}`) };
}
export async function runScript(
scriptId: string,
rawInputs: unknown,
options: RunScriptOptions = {},
): Promise<ScriptRunResult> {
const startedAt = Date.now();
const logs: ScriptLogEntry[] = [];
let httpCalls = 0;
const finish = (
partial: Pick<ScriptRunResult, 'ok' | 'outputs' | 'error'>,
): ScriptRunResult => ({
scriptId,
logs,
durationMs: Date.now() - startedAt,
httpCalls,
...partial,
});
const script = await getScript(scriptId);
if (!script) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'not_found',
message: `Skript ${scriptId} neexistuje nebo se nepodařilo načíst.`,
retryable: false,
},
});
}
const { manifest } = script;
const connection = resolveConnection(connectorIdOf(scriptId));
const redact = createRedactor(connectionSecrets(connection));
if (!connection.ready) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'config',
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
retryable: false,
},
});
}
const validatedInputs = validateValues(manifest.inputs, rawInputs, {
logLabel: `${scriptId} vstup`,
});
if (!validatedInputs.ok) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'validation',
message: 'Vstupní parametry nejsou v pořádku.',
retryable: false,
issues: validatedInputs.issues,
},
});
}
const idempotencyKey =
options.idempotencyKey ?? defaultIdempotencyKey(scriptId, validatedInputs.values);
const timeoutMs = options.timeoutMs ?? manifest.timeoutMs ?? config.scriptTimeoutMs;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const log = (message: string, detail?: unknown) => {
// Log muze cist klient, proto vzdy pres redakci a vzdy zkraceny.
logs.push({
at: new Date().toISOString(),
message: redact(describe(message, 300)),
...(detail !== undefined ? { detail: redact(describe(detail)) } : {}),
});
};
const ctx: ScriptContext = {
http: createHttp({
connection,
signal: controller.signal,
idempotencyKey,
redact,
log,
onCall: () => {
httpCalls += 1;
},
}),
util: scriptUtil,
log,
config: Object.freeze({ ...connection.config }),
idempotencyKey,
fail(message, detail) {
throw new ScriptError('terminal', message, { detail: describe(detail) });
},
retry(message, detail) {
throw new ScriptError('retryable', message, { detail: describe(detail) });
},
};
let returned: unknown;
try {
returned = await script.run(validatedInputs.values, ctx);
} catch (err) {
const error = toRunError(err, redact);
console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`);
return finish({
ok: false,
outputs: {},
error: { ...error, retryable: isRetryableKind(error.kind) },
});
} finally {
clearTimeout(timer);
}
if (controller.signal.aborted) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'timeout',
message: `Skript nedoběhl v limitu ${timeoutMs} ms.`,
retryable: true,
},
});
}
const validatedOutputs = validateValues(manifest.outputs, returned, {
logLabel: `${scriptId} výstup`,
});
if (!validatedOutputs.ok) {
// Chyba skriptu, ne uzivatele. Strom by jinak veril parametru, ktery nedosel.
console.error(
`[scripts] ${scriptId} nevratil deklarovane vystupy: ${validatedOutputs.issues
.map((issue) => issue.message)
.join(' ')}`,
);
return finish({
ok: false,
outputs: {},
error: {
kind: 'output',
message: 'Skript nevrátil parametry, které má v manifestu.',
retryable: false,
issues: validatedOutputs.issues,
},
});
}
return finish({ ok: true, outputs: validatedOutputs.values, error: null });
}
/** Vysledek jako jeden radek do logu ticketu. Az bude runtime, pouzije tohle. */
export function summarizeRun(result: ScriptRunResult): string {
if (result.ok) {
const pairs = Object.entries(result.outputs)
.map(([key, value]) => `${key}=${value === null ? '-' : String(value)}`)
.join(', ');
return `${result.scriptId} ok za ${result.durationMs} ms${pairs ? ` (${pairs})` : ''}`;
}
return `${result.scriptId} selhal: ${result.error?.message ?? 'neznámá chyba'}`;
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Co je skript konektoru.
*
* Skript je jeden soubor, ktery nese dve veci: **manifest** (jak se jmenuje,
* co potrebuje na vstupu, co vraci na vystupu) a **kod**, ktery to udela.
* Diky manifestu s nim umi pracovat strom automatizace, aniz by o jeho kodu
* cokoliv vedel.
*
* Zdroj pravdy o tvaru manifestu je zod schema tady v tomhle souboru. Typy
* se z nej odvozuji, aby nebyl na dvou mistech a jednou se nerozesel.
*
* Souvisejici navrh: documentation/09-navrh-rozsireni.md, bod 9.
*/
import { z } from 'zod';
// ------------------------------------------------------------------- hodnoty
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
export type ScriptValue = string | number | boolean | null;
export type ScriptValues = Record<string, ScriptValue>;
// -------------------------------------------------------------------- schema
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
/**
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
* strany - validace je pak taky jedna funkce, ne dve skoro stejne.
*
* `id` se pouziva v sablonach jako `{{id}}`, proto smi obsahovat jen to,
* co jde napsat bez preklepu.
*/
export const scriptFieldSchema = z
.object({
id: z
.string()
.regex(
/^[A-Za-z][A-Za-z0-9_]*$/,
'ID parametru musí začínat písmenem a obsahovat jen písmena, číslice a podtržítko.',
),
label: z.string().min(1, 'Popis parametru nesmí být prázdný.'),
type: fieldTypeSchema,
required: z.boolean(),
/** Napoveda pod polem v builderu. */
hint: z.string().optional(),
/** Vyber z hodnot. Jina hodnota neprojde validaci. */
options: z
.array(z.object({ value: z.string(), label: z.string() }))
.min(1)
.optional(),
/** Jen u typu string: dalsi kontrola regularnim vyrazem. */
pattern: z.string().optional(),
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
multiline: z.boolean().optional(),
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
})
.strict();
export type ScriptField = z.infer<typeof scriptFieldSchema>;
/**
* Manifest skriptu.
*
* `id` ma tvar `<konektor>.<operace>`, napriklad `idoklad.get-issued-invoice`.
* Z nej se dopocita, do ktereho konektoru operace patri, takze se to nepise
* dvakrat. Nazev souboru musi byt `<id>.js`.
*
* `.strict()` je zamer: preklep v nazvu klice (`outputFileds`) se ma ohlasit,
* ne tise ignorovat.
*/
export const scriptManifestSchema = z
.object({
id: z
.string()
.regex(
/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,
'ID skriptu musí mít tvar konektor.operace, například idoklad.get-issued-invoice.',
),
name: z.string().min(1, 'Název skriptu nesmí být prázdný.'),
description: z.string().min(1, 'Popis skriptu nesmí být prázdný.'),
inputs: z.array(scriptFieldSchema).default([]),
outputs: z.array(scriptFieldSchema).default([]),
/** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */
timeoutMs: z.number().int().min(1000).max(120_000).optional(),
})
.strict()
.superRefine((manifest, ctx) => {
for (const key of ['inputs', 'outputs'] as const) {
const seen = new Set<string>();
for (const field of manifest[key]) {
if (seen.has(field.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `Parametr ${field.id} je uveden dvakrát.`,
});
}
seen.add(field.id);
}
}
});
export type ScriptManifest = z.infer<typeof scriptManifestSchema>;
// --------------------------------------------------------------------- chyby
/**
* Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne:
* timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
* Opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
*/
export type ScriptErrorKind =
| 'not_found'
| 'config'
| 'validation'
| 'output'
| 'retryable'
| 'terminal'
| 'timeout'
| 'internal';
const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout'];
export function isRetryableKind(kind: ScriptErrorKind): boolean {
return retryableKinds.includes(kind);
}
export class ScriptError extends Error {
readonly kind: ScriptErrorKind;
/** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
readonly status?: number;
/** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */
readonly detail?: string;
constructor(
kind: ScriptErrorKind,
message: string,
options: { status?: number; detail?: string; cause?: unknown } = {},
) {
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'ScriptError';
this.kind = kind;
this.status = options.status;
this.detail = options.detail;
}
}
// ------------------------------------------------------------------- kontext
export interface ScriptHttpResponse<T = unknown> {
status: number;
body: T;
}
export interface ScriptHttpOptions {
query?: Record<string, string | number | boolean | undefined | null>;
headers?: Record<string, string>;
/** Kody, ktere se nemaji brat jako chyba. Vychozi je 2xx. */
allowStatus?: number[];
}
/**
* HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
* napojeni, takze **skript se k pristupovym udajum nedostane**.
*/
export interface ScriptHttp {
get<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
post<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
patch<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
put<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
}
/**
* Pomocne funkce. Jsou na kontextu, ne v importu, ze dvou duvodu: skript
* nemusi resit relativni cesty a stejny podpis bude fungovat i pozdeji
* v sandboxu, kde zadny import neni.
*/
export interface ScriptUtil {
/** Rozbali obalku odpovedi, tedy `{ Data: ... }` i `{ data: ... }`. */
unwrap<T = unknown>(body: unknown): T;
/** Prvni existujici pole bez ohledu na velka a mala pismena. */
pick(source: unknown, ...names: string[]): unknown;
/** Prvni prvek pole, nebo null. */
first<T = unknown>(value: unknown): T | null;
text(value: unknown, fallback?: string | null): string | null;
num(value: unknown, fallback?: number | null): number | null;
bool(value: unknown): boolean;
/** Datum jako ISO retezec, nebo null. */
date(value: unknown): string | null;
/** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */
round(value: number, decimals?: number): number;
/**
* Vrati hodnotu, nebo skonci chybou s citelnou zpravou.
* Pro povinne vystupy: kdyz je cizi odpoved jina, nez skript ceka, ma se to
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
*/
need<T>(value: T | null | undefined, label: string): T;
}
export interface ScriptContext {
http: ScriptHttp;
util: ScriptUtil;
/** Zapise radek do logu behu. Nikdy sem nedavat pristupove udaje. */
log(message: string, detail?: unknown): void;
/** Necitliva cast nastaveni napojeni. */
config: Readonly<Record<string, string>>;
/**
* Stabilni pres vsechny pokusy tehoz kroku. Predava se cizim sluzbam jako
* `Idempotency-Key`, aby druhy pokus nevystavil druhou fakturu.
*/
idempotencyKey: string;
/** Koncova chyba, neopakuje se. Typicky nesmyslny vstup nebo 404 od sluzby. */
fail(message: string, detail?: unknown): never;
/** Opakovatelna chyba. Typicky vypadek nebo docasna nedostupnost. */
retry(message: string, detail?: unknown): never;
}
/** Co soubor skriptu exportuje. */
export interface ScriptModule {
manifest: unknown;
run: (inputs: ScriptValues, ctx: ScriptContext) => Promise<unknown> | unknown;
}
// -------------------------------------------------------------------- vysledek
export interface ScriptLogEntry {
at: string;
message: string;
detail?: string;
}
export interface ScriptRunError {
kind: ScriptErrorKind;
message: string;
retryable: boolean;
status?: number;
detail?: string;
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
issues?: FieldIssue[];
}
export interface FieldIssue {
field: string;
message: string;
}
export interface ScriptRunResult {
ok: boolean;
scriptId: string;
/** Prazdne, kdyz beh selhal. */
outputs: ScriptValues;
logs: ScriptLogEntry[];
durationMs: number;
httpCalls: number;
error: ScriptRunError | null;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Pomocne funkce predane skriptu jako `ctx.util`, plus redakce tajemstvi.
*
* Duvod, proc to neni v kazdem skriptu znovu: cizi API vraci pokazde jinak.
* iDoklad pouziva velka pocatecni pismena a nekde obaluje odpoved do `Data`,
* jine sluzby ne. Bez `unwrap` a `pick` by kazdy skript resil totez a jeden
* z nich by to resil spatne.
*/
import { ScriptError, type ScriptUtil } from './types.js';
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
function unwrap<T = unknown>(body: unknown): T {
if (body === null || typeof body !== 'object') return body as T;
const record = body as Record<string, unknown>;
if ('Data' in record) return record.Data as T;
if ('data' in record) return record.data as T;
return body as T;
}
/**
* Prvni existujici pole bez ohledu na velikost pismen.
* `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`.
*/
function pick(source: unknown, ...names: string[]): unknown {
if (source === null || typeof source !== 'object') return undefined;
const record = source as Record<string, unknown>;
for (const name of names) {
if (record[name] !== undefined) return record[name];
}
const lowered = new Map<string, unknown>();
for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
for (const name of names) {
const value = lowered.get(name.toLowerCase());
if (value !== undefined) return value;
}
return undefined;
}
function first<T = unknown>(value: unknown): T | null {
if (Array.isArray(value)) return (value[0] as T) ?? null;
const unwrapped = unwrap(value);
if (Array.isArray(unwrapped)) return (unwrapped[0] as T) ?? null;
return null;
}
function text(value: unknown, fallback: string | null = null): string | null {
if (value === undefined || value === null) return fallback;
if (typeof value === 'object') return fallback;
const result = String(value).trim();
return result === '' ? fallback : result;
}
function num(value: unknown, fallback: number | null = null): number | null {
if (value === undefined || value === null || value === '') return fallback;
const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isFinite(parsed) ? parsed : fallback;
}
function bool(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const lowered = String(value ?? '').trim().toLowerCase();
return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano';
}
function date(value: unknown): string | null {
if (value === undefined || value === null || value === '') return null;
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value));
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
}
function round(value: number, decimals = 2): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
function need<T>(value: T | null | undefined, label: string): T {
if (value === undefined || value === null || value === '') {
throw new ScriptError('output', `Odpověď služby neobsahuje ${label}.`);
}
return value;
}
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
// ------------------------------------------------------------------- redakce
/**
* Nahradi tajne hodnoty hvezdickami.
*
* Neni to kosmetika. Log ticketu ukazuje, co sluzba vratila, a cizi API rado
* vraci prijaty token v chybove zprave. Bez redakce by tajemstvi skoncilo
* v logu, ktery se navic zobrazuje klientovi.
*/
export function createRedactor(secrets: Array<string | undefined>): (value: string) => string {
// Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text.
const values = secrets
.filter((value): value is string => typeof value === 'string' && value.length >= 6)
.sort((a, b) => b.length - a.length);
if (values.length === 0) return (value) => value;
return (value: string) => {
let result = value;
for (const secret of values) result = result.split(secret).join('***');
return result;
};
}
/** Zkrati text na danou delku, aby jeden log nezabral megabajt. */
export function truncate(value: string, max = 600): string {
if (value.length <= max) return value;
return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`;
}
/** Bezpecne prevede cokoliv na kratky text do logu. */
export function describe(value: unknown, max = 600): string {
if (value === undefined) return '';
if (typeof value === 'string') return truncate(value, max);
try {
return truncate(JSON.stringify(value) ?? String(value), max);
} catch {
return truncate(String(value), max);
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Kontrola parametru skriptu.
*
* Jedna funkce pro vstup i vystup. Kdyby to byly dve, jedna by se casem
* opravila a druha ne, a strom by pak veril vystupu, ktery nikdo neoveril.
*
* Pravidla:
* - povinny parametr bez hodnoty je chyba, ne prazdny retezec,
* - nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
* - hodnota se prevede na deklarovany typ, kdyz to jde bez hadani,
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
*/
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
export type ValidationResult =
| { ok: true; values: ScriptValues }
| { ok: false; issues: FieldIssue[] };
/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */
const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
function isMissing(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
/**
* Prevede jednu hodnotu na deklarovany typ.
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
*/
function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { error: string } {
switch (field.type) {
case 'string': {
if (typeof raw === 'object') return { error: 'Očekává se text, přišel objekt.' };
const text = field.multiline ? String(raw) : String(raw).trim();
if (field.pattern) {
let regex: RegExp;
try {
regex = new RegExp(field.pattern);
} catch {
return { error: `Manifest má neplatný pattern: ${field.pattern}` };
}
if (!regex.test(text)) return { error: `Hodnota neodpovídá tvaru ${field.pattern}.` };
}
return { value: text };
}
case 'number': {
if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' };
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw;
const num = typeof text === 'number' ? text : Number(text);
if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` };
return { value: num };
}
case 'boolean': {
if (typeof raw === 'boolean') return { value: raw };
const text = String(raw).trim().toLowerCase();
if (truthy.has(text)) return { value: true };
if (falsy.has(text)) return { value: false };
return { error: `"${String(raw)}" není ano ani ne.` };
}
case 'date': {
const text = raw instanceof Date ? raw.toISOString() : String(raw).trim();
const parsed = Date.parse(text);
if (Number.isNaN(parsed)) return { error: `"${text}" není platné datum.` };
return { value: new Date(parsed).toISOString() };
}
default: {
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
return { error: 'Neznámý typ parametru.' };
}
}
}
export interface ValidateOptions {
/** Kam se zapisuje varovani o parametrech navic. */
logLabel: string;
}
/**
* Overi a prevede sadu hodnot proti deklaraci parametru.
* Vraci vsechny chyby najednou, ne jen prvni - uzivatel ma opravit vse.
*/
export function validateValues(
fields: ScriptField[],
raw: unknown,
options: ValidateOptions,
): ValidationResult {
const source: Record<string, unknown> =
raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const issues: FieldIssue[] = [];
const values: ScriptValues = {};
for (const field of fields) {
const incoming = source[field.id];
if (isMissing(incoming)) {
if (field.required) {
issues.push({ field: field.id, message: `${field.label} je povinné.` });
continue;
}
values[field.id] = field.default ?? null;
continue;
}
const result = coerce(field, incoming);
if ('error' in result) {
issues.push({ field: field.id, message: `${field.label}: ${result.error}` });
continue;
}
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
const allowed = field.options.map((option) => option.value).join(', ');
issues.push({
field: field.id,
message: `${field.label}: povolené hodnoty jsou ${allowed}.`,
});
continue;
}
values[field.id] = result.value;
}
// Parametry navic neodmitame, jen o nich chceme vedet. Stejne jako u webhooku.
const declared = new Set(fields.map((field) => field.id));
const extra = Object.keys(source).filter((key) => !declared.has(key));
if (extra.length > 0) {
console.warn(`[scripts] ${options.logLabel}: parametry mimo manifest: ${extra.join(', ')}`);
}
return issues.length > 0 ? { ok: false, issues } : { ok: true, values };
}
+2
View File
@@ -17,6 +17,7 @@ const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
@@ -61,6 +62,7 @@ export default function App() {
<Route path="automatizace" element={<Automations />} />
<Route path="automatizace/:id" element={<AutomationDetail />} />
<Route path="konektory" element={<Connectors />} />
<Route path="skripty" element={<Scripts />} />
<Route path="tickety" element={<Tickets />} />
<Route path="tickety/:id" element={<TicketDetail />} />
<Route path="incidenty" element={<Incidents />} />
@@ -7,6 +7,7 @@ import {
LogOut,
Menu,
Plug,
ScrollText,
Settings,
Workflow,
X,
@@ -27,6 +28,7 @@ const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
{ to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false },
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
+20 -1
View File
@@ -25,10 +25,29 @@ export class ApiError extends Error {
message: string,
readonly status: number,
readonly code?: string,
/** Cele telo odpovedi. Nektere endpointy vraci vedle zpravy i podrobnosti. */
readonly payload?: unknown,
) {
super(message);
this.name = 'ApiError';
}
/**
* Dilci problemy z odpovedi, napriklad co presne v manifestu skriptu nesedi.
* Prazdne pole, kdyz je odpoved neposila - volajici pak nemusi nic hlidat.
*/
issues(): Array<{ field: string; message: string }> {
if (this.payload === null || typeof this.payload !== 'object') return [];
const value = (this.payload as { issues?: unknown }).issues;
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is { field: string; message: string } =>
item !== null &&
typeof item === 'object' &&
typeof (item as { field?: unknown }).field === 'string' &&
typeof (item as { message?: unknown }).message === 'string',
);
}
}
export function getToken(): string | null {
@@ -85,7 +104,7 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
? String((payload as { error: unknown }).error)
: undefined;
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
throw new ApiError(message, response.status, code);
throw new ApiError(message, response.status, code, isJson ? payload : undefined);
}
return payload as T;
+23 -10
View File
@@ -1,4 +1,4 @@
import { CheckCircle2, Clock, Plug, Search, Zap } from 'lucide-react';
import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
@@ -8,7 +8,13 @@ import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Connector, ConnectorCatalog, ConnectorCategory, ConnectorStatus } from '@/types/dashboard';
import type {
Connector,
ConnectorCatalog,
ConnectorCategory,
ConnectorOperation,
ConnectorStatus,
} from '@/types/dashboard';
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
connected: { label: 'Napojeno', tone: 'ok' },
@@ -223,13 +229,13 @@ function ConnectorCard({ connector }: { connector: Connector }) {
<OperationList
title="Spouštěče"
icon={<Zap className="size-3 text-brand-300" />}
names={connector.triggers.map((t) => t.name)}
operations={connector.triggers}
emptyLabel="Nelze použít jako spouštěč"
/>
<OperationList
title="Akce"
icon={<Plug className="size-3 text-accent-300" />}
names={connector.actions.map((a) => a.name)}
operations={connector.actions}
emptyLabel="Žádné akce"
/>
</div>
@@ -240,12 +246,12 @@ function ConnectorCard({ connector }: { connector: Connector }) {
function OperationList({
title,
icon,
names,
operations,
emptyLabel,
}: {
title: string;
icon: ReactNode;
names: string[];
operations: ConnectorOperation[];
emptyLabel: string;
}) {
return (
@@ -254,13 +260,20 @@ function OperationList({
{icon}
{title}
</p>
{names.length === 0 ? (
{operations.length === 0 ? (
<p className="text-xs text-white/25">{emptyLabel}</p>
) : (
<ul className="space-y-1">
{names.map((name) => (
<li key={name} className="text-sm text-white/60">
{name}
{operations.map((operation) => (
<li key={operation.id} className="flex items-start gap-1.5 text-sm text-white/60">
<span>{operation.name}</span>
{/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
{operation.implementation === 'script' && (
<Terminal
className="mt-1 size-3 shrink-0 text-ok-400"
aria-label="Obsluhuje skript, opravdu se vykoná"
/>
)}
</li>
))}
</ul>
+668
View File
@@ -0,0 +1,668 @@
import {
AlertTriangle,
CheckCircle2,
Code2,
FileWarning,
Play,
RefreshCw,
Save,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
ConnectionStatus,
ScriptCatalog,
ScriptDetail,
ScriptField,
ScriptRunResult,
} from '@/types/dashboard';
/**
* Vykonna cast konektoru. Jeden skript = jeden soubor, ktery nese manifest
* (vstupy a vystupy) a kod. Upravit ho jde tady i rucne v souboru, server si
* zmenu vsimne sam - nic se nerestartuje.
*
* Popis modelu je v documentation/11-skripty-konektoru.md.
*/
export default function Scripts() {
usePageMeta({ title: 'Skripty konektorů - portál Automia' });
const { user } = useAuth();
const canEdit = user?.platformAdmin === true;
const catalog = useApiQuery<ScriptCatalog>('/api/dashboard/scripts');
const [selectedId, setSelectedId] = useState<string | null>(null);
const items = catalog.data?.items ?? [];
// Prvni skript se vybere sam, jinak by stranka po nacteni vypadala prazdne.
useEffect(() => {
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
}, [items, selectedId]);
const connectionsById = useMemo(() => {
const map = new Map<string, ConnectionStatus>();
for (const connection of catalog.data?.connections ?? []) {
map.set(connection.connectorId, connection);
}
return map;
}, [catalog.data]);
const grouped = useMemo(() => {
const map = new Map<string, ScriptCatalog['items']>();
for (const item of items) {
const list = map.get(item.connectorId) ?? [];
list.push(item);
map.set(item.connectorId, list);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [items]);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Skripty konektorů</h1>
<p className="mt-1 text-sm text-white/50">
Výkonná část konektorů. Každý skript nese vstupní i výstupní parametry, takže
s ním umí pracovat strom automatizace. Upravit ho jde tady nebo přímo v souboru,
server si změny všimne sám.
</p>
</header>
<DataState
loading={catalog.loading}
error={catalog.error}
onRetry={catalog.reload}
empty={items.length === 0 && (catalog.data?.problems.length ?? 0) === 0}
emptyLabel="Ve složce skriptů nic není."
>
<div className="space-y-6">
{catalog.data && <Problems problems={catalog.data.problems} />}
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="space-y-5">
{grouped.map(([connectorId, scripts]) => (
<ConnectorGroup
key={connectorId}
connectorId={connectorId}
connection={connectionsById.get(connectorId)}
scripts={scripts}
selectedId={selectedId}
onSelect={setSelectedId}
/>
))}
{catalog.data && (
<p className="px-1 text-xs break-all text-white/30">
Složka: <code>{catalog.data.directory}</code>
</p>
)}
</aside>
{selectedId ? (
<ScriptPanel
key={selectedId}
scriptId={selectedId}
canEdit={canEdit}
onSaved={catalog.reload}
/>
) : (
<p className="py-10 text-center text-sm text-white/40">Vyberte skript vlevo.</p>
)}
</div>
</div>
</DataState>
</div>
);
}
// ------------------------------------------------------------------- seznam
function ConnectorGroup({
connectorId,
connection,
scripts,
selectedId,
onSelect,
}: {
connectorId: string;
connection: ConnectionStatus | undefined;
scripts: ScriptCatalog['items'];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<div className="glass rounded-card p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="font-semibold text-white">{connectorId}</p>
{connection && (
<Badge tone={connection.ready ? 'ok' : 'warn'}>
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
</Badge>
)}
</div>
<ul className="space-y-1">
{scripts.map((script) => (
<li key={script.id}>
<button
type="button"
onClick={() => onSelect(script.id)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
script.id === selectedId
? 'bg-brand-500/12 text-brand-200'
: 'text-white/60 hover:bg-white/5 hover:text-white',
)}
>
{script.name}
<span className="mt-0.5 block font-mono text-xs text-white/30">
{script.operationId}
</span>
</button>
</li>
))}
</ul>
</div>
);
}
function Problems({ problems }: { problems: ScriptCatalog['problems'] }) {
if (problems.length === 0) return null;
return (
<div className="rounded-card border border-danger-500/40 bg-danger-500/8 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-danger-400">
<FileWarning className="size-4 shrink-0" />
{problems.length === 1 ? 'Jeden skript se nenačetl' : `${problems.length} skriptů se nenačetlo`}
</p>
<ul className="mt-2 space-y-2 text-sm text-white/70">
{problems.map((problem) => (
<li key={problem.file}>
<code className="text-white/90">{problem.file}</code>: {problem.message}
{problem.issues && problem.issues.length > 0 && (
<ul className="mt-1 ml-4 list-disc text-xs text-white/50">
{problem.issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>
{issue.field}: {issue.message}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
// ------------------------------------------------------------------- detail
function ScriptPanel({
scriptId,
canEdit,
onSaved,
}: {
scriptId: string;
canEdit: boolean;
onSaved: () => void;
}) {
const [detail, setDetail] = useState<ScriptDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
setError(null);
apiFetch<ScriptDetail>(`/api/dashboard/scripts/${scriptId}`)
.then(setDetail)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Skript se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, [scriptId]);
useEffect(load, [load]);
return (
<DataState loading={loading} error={error} onRetry={load}>
{detail && (
<div className="space-y-5">
<div className="glass rounded-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold text-white">{detail.manifest?.name ?? detail.id}</h2>
<p className="mt-1 font-mono text-xs text-white/35">{detail.id}.js</p>
</div>
<ConnectionBadge connection={detail.connection} />
</div>
{detail.manifest && (
<p className="mt-3 text-sm text-white/55">{detail.manifest.description}</p>
)}
{detail.problem && (
<p className="mt-3 flex items-start gap-2 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{detail.problem.message}
</p>
)}
{!detail.connection.ready && (
<p className="mt-3 text-sm text-warn-400">
Napojení není nastavené, skript nepůjde spustit. Chybí:{' '}
<code>{detail.connection.missing.join(', ')}</code>. Nastavte je jako
proměnné aplikace v AppFactory.
</p>
)}
</div>
{detail.manifest && (
<div className="grid gap-5 lg:grid-cols-2">
<FieldTable title="Vstupní parametry" fields={detail.manifest.inputs} />
<FieldTable title="Výstupní parametry" fields={detail.manifest.outputs} />
</div>
)}
{detail.manifest && canEdit && (
<TestPanel
scriptId={detail.id}
fields={detail.manifest.inputs}
ready={detail.connection.ready}
/>
)}
<CodeEditor
scriptId={detail.id}
code={detail.code}
canEdit={canEdit}
onSaved={() => {
load();
onSaved();
}}
/>
</div>
)}
</DataState>
);
}
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
return (
<div className="text-right">
<Badge tone={connection.ready ? 'ok' : 'warn'}>
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
</Badge>
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
</div>
);
}
/**
* Tabulka parametru. Zamerne jedna komponenta pro vstupy i vystupy - je to
* tentyz tvar dat a dve skoro stejne tabulky by se rozesly.
*/
function FieldTable({ title, fields }: { title: string; fields: ScriptField[] }) {
return (
<div className="glass rounded-card p-5">
<p className="mb-3 text-xs font-semibold tracking-wide text-white/45 uppercase">{title}</p>
{fields.length === 0 ? (
<p className="text-sm text-white/30">Žádné.</p>
) : (
<ul className="space-y-2.5">
{fields.map((field) => (
<li key={field.id} className="border-b border-ink-600/40 pb-2.5 last:border-0 last:pb-0">
<p className="flex flex-wrap items-center gap-2 text-sm">
<code className="text-white">{field.id}</code>
<span className="text-xs text-white/35">{field.type}</span>
{field.required && <Badge tone="warn">povinné</Badge>}
</p>
<p className="mt-0.5 text-sm text-white/55">{field.label}</p>
{field.hint && <p className="mt-0.5 text-xs text-white/35">{field.hint}</p>}
</li>
))}
</ul>
)}
</div>
);
}
// --------------------------------------------------------------------- test
/**
* Jedno vstupni pole podle deklarovaneho typu. Diky tomu neni testovaci
* formular rucne psany pro kazdy skript, ale vznika z manifestu.
*/
function FieldInput({
field,
value,
onChange,
}: {
field: ScriptField;
value: string;
onChange: (value: string) => void;
}) {
const className =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
const control = (): ReactNode => {
if (field.options) {
return (
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Nevyplněno</option>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (field.type === 'boolean') {
return (
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Nevyplněno</option>
<option value="true">Ano</option>
<option value="false">Ne</option>
</select>
);
}
if (field.multiline) {
return (
<textarea
rows={3}
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
return (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
};
return (
<label className="block">
<span className="mb-1 block text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400"> *</span>}
</span>
{control()}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
function TestPanel({
scriptId,
fields,
ready,
}: {
scriptId: string;
fields: ScriptField[];
ready: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ScriptRunResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
async function handleRun() {
setRunning(true);
setError(null);
setResult(null);
try {
// Prazdne hodnoty se neposilaji, aby se uplatnily vychozi hodnoty z manifestu.
const inputs = Object.fromEntries(
Object.entries(values).filter(([, value]) => value !== ''),
);
setResult(await apiFetch<ScriptRunResult>(`/api/dashboard/scripts/${scriptId}/test`, {
method: 'POST',
body: { inputs },
}));
} catch (err: unknown) {
const message =
err instanceof ApiError ? err.message : 'Zkušební spuštění se nepodařilo odeslat.';
setError(message);
} finally {
setRunning(false);
}
}
return (
<div className="glass rounded-card p-5">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Zkušební spuštění
</p>
<p className="mt-1 mb-4 text-sm text-warn-400">
Volá se opravdová služba. Co skript založí, opravdu vznikne.
</p>
{fields.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
)}
<div className="mt-4 flex items-center gap-3">
<Button size="sm" onClick={handleRun} disabled={running || !ready}>
<Play className="size-4" />
{running ? 'Spouštím...' : 'Spustit'}
</Button>
{!ready && <span className="text-sm text-white/40">Nejdřív nastavte napojení.</span>}
</div>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
{result && <RunResult result={result} />}
</div>
);
}
function RunResult({ result }: { result: ScriptRunResult }) {
return (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<p
className={cn(
'flex items-center gap-2 text-sm font-semibold',
result.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertTriangle className="size-4 shrink-0" />
)}
{result.ok ? 'Proběhlo' : 'Selhalo'}
<span className="font-normal text-white/40">
{result.durationMs} ms, volání služby: {result.httpCalls}
</span>
</p>
{result.error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{result.error.message}</p>
<p className="mt-1 text-xs text-white/40">
Druh: {result.error.kind}
{result.error.status ? `, HTTP ${result.error.status}` : ''},{' '}
{result.error.retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}
</p>
{result.error.issues && result.error.issues.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
{result.error.issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
{result.error.detail && (
<pre className="mt-2 overflow-x-auto rounded bg-ink-950/60 p-2 text-xs text-white/50">
{result.error.detail}
</pre>
)}
</div>
)}
{result.ok && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Výstupy
</p>
<ul className="space-y-1 text-sm">
{Object.entries(result.outputs).map(([key, value]) => (
<li key={key} className="flex flex-wrap gap-2">
<code className="text-brand-200">{key}</code>
<span className="text-white/70">{value === null ? '-' : String(value)}</span>
</li>
))}
</ul>
</div>
)}
{result.logs.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</p>
<ul className="space-y-1 font-mono text-xs text-white/50">
{result.logs.map((entry, index) => (
<li key={`${entry.at}-${index}`}>
{entry.message}
{entry.detail && <span className="text-white/30"> {entry.detail}</span>}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// -------------------------------------------------------------------- editor
function CodeEditor({
scriptId,
code,
canEdit,
onSaved,
}: {
scriptId: string;
code: string;
canEdit: boolean;
onSaved: () => void;
}) {
const [draft, setDraft] = useState(code);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
const [saved, setSaved] = useState(false);
// Po nacteni jineho skriptu nebo po ulozeni se koncept srovna se serverem.
useEffect(() => {
setDraft(code);
setIssues([]);
setError(null);
}, [code]);
const dirty = draft !== code;
async function handleSave() {
setSaving(true);
setError(null);
setIssues([]);
setSaved(false);
try {
await apiFetch(`/api/dashboard/scripts/${scriptId}`, { method: 'PUT', body: { code: draft } });
setSaved(true);
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
// Server posila v `issues`, co presne v manifestu nesedi.
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
return (
<div className="glass rounded-card p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-xs font-semibold tracking-wide text-white/45 uppercase">
<Code2 className="size-3.5" />
Kód skriptu
</p>
{canEdit ? (
<div className="flex items-center gap-3">
{dirty && <span className="text-xs text-warn-400">Neuložené změny</span>}
{saved && !dirty && <span className="text-xs text-ok-400">Uloženo</span>}
<Button
size="sm"
variant="secondary"
onClick={() => setDraft(code)}
disabled={!dirty || saving}
>
<RefreshCw className="size-4" />
Zahodit
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
</div>
) : (
<span className="text-xs text-white/35">Upravovat smí jen správce platformy.</span>
)}
</div>
<textarea
spellCheck={false}
readOnly={!canEdit}
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="h-96 w-full resize-y rounded-xl border border-ink-600/70 bg-ink-950/70 p-4 font-mono text-xs leading-relaxed text-white/85 focus:border-brand-400/70 focus:outline-none"
/>
{error && (
<div className="mt-3 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{error}</p>
{issues.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>
{issue.field}: {issue.message}
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-white/40">
Nic se neuložilo, původní skript funguje dál.
</p>
</div>
)}
</div>
);
}
+97
View File
@@ -190,6 +190,12 @@ export interface ConnectorOperation {
inputs?: OperationField[];
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
outputFields?: TriggerField[];
/**
* `script` = operaci obsluhuje skript, tedy se opravdu vykona.
* Kdyz chybi, je to zatim jen zapis v katalogu.
*/
implementation?: 'script';
scriptId?: string;
}
/**
@@ -353,3 +359,94 @@ export interface LayoutResponse {
/** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */
custom: boolean;
}
// ------------------------------------------------------- skripty konektoru
/**
* Parametr skriptu. Stejny tvar pro vstup i vystup.
* Zdroj pravdy je manifest na serveru, viz src/scripts/types.ts.
*/
export interface ScriptField {
id: string;
label: string;
type: FieldType;
required: boolean;
hint?: string;
options?: Array<{ value: string; label: string }>;
pattern?: string;
multiline?: boolean;
default?: string | number | boolean | null;
}
export interface ScriptManifest {
id: string;
connectorId: string;
operationId: string;
name: string;
description: string;
inputs: ScriptField[];
outputs: ScriptField[];
timeoutMs?: number;
}
export interface ScriptProblem {
file: string;
scriptId: string | null;
message: string;
issues?: Array<{ field: string; message: string }>;
}
/** Stav napojeni. Hodnoty pristupovych udaju se ze serveru nikdy nevraci. */
export interface ConnectionStatus {
connectorId: string;
baseUrl: string;
ready: boolean;
/** Jmena chybejicich environment variables. */
missing: string[];
headers: string[];
}
export interface ScriptCatalog {
items: ScriptManifest[];
problems: ScriptProblem[];
connections: ConnectionStatus[];
directory: string;
}
export interface ScriptDetail {
id: string;
connectorId: string;
operationId: string;
/** null = soubor je rozbity, manifest se nepodarilo precist. */
manifest: ScriptManifest | null;
code: string;
problem: ScriptProblem | null;
connection: ConnectionStatus;
}
export type ScriptErrorKind =
| 'not_found'
| 'config'
| 'validation'
| 'output'
| 'retryable'
| 'terminal'
| 'timeout'
| 'internal';
export interface ScriptRunResult {
ok: boolean;
scriptId: string;
outputs: Record<string, string | number | boolean | null>;
logs: Array<{ at: string; message: string; detail?: string }>;
durationMs: number;
httpCalls: number;
error: {
kind: ScriptErrorKind;
message: string;
retryable: boolean;
status?: number;
detail?: string;
issues?: Array<{ field: string; message: string }>;
} | null;
}