Jak spustitelný soubor v Rustu pod apps/majordomus-cli/ spouští schopnost jako práci, kterou lze sledovat: model, životní cyklus, typovaný protokol událostí, živý kanál, co nabízí každé rozhraní a co záměrně nedělá. Chování tak, jak je implementováno a testováno; kde se tento dokument a spustitelný soubor liší, dokument se mýlí a mění se ve stejném commitu. Rozhodnutí je ADR 22; pravidla jsou project.execution-events-are-typed a project.executions-carry-no-secret; registr, na kterém běží, je CAPABILITIES.md.
flowchart TD
declaration["ONE CANONICAL DECLARATION<br/><small>capability! { … handler: verify }.cancellable()</small>"]
registry["CAPABILITY REGISTRY<br/><small>the same registry MCP, HTTP, OpenAPI, the CLI and the Cockpit read</small>"]
engine["EXECUTION ENGINE<br/><small>validate · create · queue · run on a worker · catch · complete</small>"]
store["EXECUTION STORE<br/><small>the snapshot, the retained events, the subscribers, all bounded</small>"]
declaration --> registry --> engine --> store
subgraph projections["DERIVED PROJECTIONS"]
channel["the live channel<br/><small>WebSocket</small>"]
get["executions.get"]
events["executions.events"]
pages["the Cockpit's Executions pages"]
run["majordomus run"]
tools["MCP tools"]
end
store --> projections
Odkazováno z tohoto diagramu (2)
Neexistuje registr akcí, definice akce ani druhá implementace čehokoli. Provedení je volání schopnosti, kterému proces dal identitu — a od I1501 jedna rovina provádění na checkout, a ne jedna na proces: majordomus run hlásí serveru, který checkout obsluhuje, takže historie, kterou povrch ukazuje, je historie, kterou člověk vytvořil.
Model
| Provádění | jedno volání jedné schopnosti, s id, životním cyklem, vstupem tak, jak byl uložen, výstupem nebo chybou, kroky, průběhem, diagnostikou, údajem, kdo o něj požádal, a repozitářem, proti kterému běželo |
| Událost provedení | jeden typovaný fakt o provedení, v obálce s verzí protokolu a souvislým pořadovým číslem |
| Politika provádění | co spuštění schopnosti znamená: její účinek a zda se dvě smějí překrývat, klasifikováno podle druhu; a zda její zrušení něčeho dosáhne, což může říct jen její handler |
| Průběh | handle, který nese každý kontext; mimo provedení tichý, takže handler se píše jednou pro každého volajícího |
Životní cyklus
stateDiagram-v2 queued --> running queued --> cancelled running --> succeeded running --> failed running --> cancelling cancelling --> cancelled cancelling --> succeeded: it finished before it noticed cancelling --> failed
ExecutionState::may_move_to je celá smlouva a úložiště odmítne cokoli jiného, takže klient, který přečte konečný stav, ho už nikdy neuvidí se pohnout. Neexistuje starting: engine v procesu převezme provedení a vstoupí do svého handleru ve stejném okamžiku a stav, kterého nelze dosáhnout, je stav, který by musel zvládat každý klient a nikdy by ho neviděl.
Co tu není
Provádění nepřežije server, který jej drží. Není žádná trvalá fronta, žádný retry, žádný plán a žádné provádění, které přežije restart. Každý limit je typován na execution::store::Limits — kolik provádění se pamatuje, kolik událostí každé, jak dlouhý smí být řádek logu, kolik jich běží najednou, jak daleko smí odběratel zaostat — a každý má definované chování na hranici místo nenapsaného. Nic nad úložištěm nejmenuje Mutex, takže trvalé úložiště je záměna, ne přepis.
Spuštění jednoho
Jakoukoli spustitelnou schopnost registru lze spustit jako provádění. Neexistuje druhý seznam toho, co smí běžet: odpovídá už capabilities.list a vlastní politika execution deskriptoru říká, co její spuštění znamená.
majordomus run health.report # run it here, follow it to the end, and
# report it to the server serving this checkout
majordomus run objects.verify --input '{"limit":50}' --format json
majordomus executions list # what the server serving this repository ran
# or was told about by a run
majordomus executions show <id>
majordomus executions events <id> --after 42
majordomus executions cancel <id>
majordomus executions protocol # the live channel's contractPOST /api/v1/executions/start { "capability": "objects.verify", "input": { … } }
GET /api/v1/executions ?state=running&capability=objects.verify&limit=50
GET /api/v1/executions/get ?id=x-…
GET /api/v1/executions/events ?id=x-…&after=42&limit=500
POST /api/v1/executions/cancel { "id": "x-…" }
GET /api/v1/executions/protocol
GET /api/v1/executions/demonstrate ?steps=3&delay_ms=200
GET /api/v1/objects/verify ?kind=rule&limit=50
POST /api/v1/executions/report { "id": "x-…", "pid": 4242, "events": [ … ], … } what `run` sends
Přes MCP jsou tytéž schopnosti nástroji majordomus_execution_start, majordomus_executions, majordomus_execution, majordomus_execution_events, majordomus_execution_cancel a majordomus_execution_protocol, přičemž majordomus://executions a majordomus://executions/protocol jsou zdroje. Agent tak objeví, spustí, sleduje a přečte provedení, aniž by parsoval cokoli, na co se měl dívat člověk. executions.report nemá žádný nástroj MCP ani vlastní příkaz: je to to, co běh posílá serveru, nikoli něco, o co žádá agent nebo člověk.
start odpoví okamžitě s provedením a odkazy, jak ho sledovat; nic nečeká na handler. Stav v této první odpovědi je queued, což je to, čím je: tento server odpovídá 200 pravdou místo 202 slibem.
Jedna rovina na checkout: majordomus run a server
majordomus run se provádí ve vlastním procesu — cena a způsoby selhání běhu jsou stejné se serverem i bez něj — a nejdřív klade jednu otázku: obsluhuje už nějaký server tento checkout (jeho pronájem pojmenovává URL a server tam odpovídá za tento kořen)?
- Server odpovídá. Běh se mu hlásí přes
executions.report: svým popisem, pid procesu a svými událostmi po poslední posloupnosti, kterou server převzal, nejpozději každé dvě sekundy a brzy poté, co se cokoli stane. Server ho drží v témže úložišti jako vlastní, takžeexecutions list,/cockpit/executions, vlastní stránka provedení,executions.eventsa živý kanál ho ukazují jako cokoli, co server spustil, sactor: {"kind": "cli", "pid": …}. Každá událost se aplikuje jednou a v pořadí a odpověď říká, jak daleko server došel (through), takže ztracenou, useknutou nebo dvakrát odeslanou zprávu napraví ta další. Zastavení vyžádané od serveru (executions.cancel, tlačítko Cockpitu) se vrátí v příští odpovědi a běh zruší své vlastní provedení; zrušení zůstává kooperativní. Výstup nebo vstup přesahující mez rámce živého kanálu putuje jako{"withheld": "too_large", …}a zůstává v procesu běhu, který ho vypíše. - Žádný server neodpovídá. Běh proběhne jako dříve a řekne to jednou na stderr: žádný server tento repozitář neposkytuje, takže toto provádění je viditelné jen tomuto procesu. Nic se pro něj nespouští: běh nikdy nespustí server.
- Server uprostřed běhu přestane odpovídat. První neúspěšné hlášení, nebo tři za sebou po jednom úspěšném, způsobí, že běh na stderr oznámí, že je od té chvíle viditelný jen tomuto procesu, a pokračuje. Server, který 30 sekund neuslyší nic, provedení ukončí chybou
unavailablea zprávou pojmenovávající proces, místo aby ho ukazoval jako věčně běžící; zabitý běh tedy nikdy není přízrakem v seznamu.
Co se tím nemění: práce běží tam, kde je běh, nikdy v serveru, a historie je stále pamětí serveru — končí s tímto serverem.
Protokol událostí
Jedna obálka, uzavřená množina payloadů, verze. type a data jsou jediní dva členové, kteří se mění, takže klient přepíná podle jednoho řetězce.
{
"schema_version": "1",
"event_id": "x-20260908T010203Z-0a1b2c3d#17",
"execution_id": "x-20260908T010203Z-0a1b2c3d",
"sequence": 17,
"timestamp": "2026-09-08T01:02:07Z",
"type": "execution.progress",
"data": { "current": 17, "total": 42, "message": "137 file(s) read" }
}| typ | kdy |
|---|---|
execution.created | existuje; nese schopnost a vstup tak, jak byly uloženy |
execution.queued | čeká na pracovníka a kolik jich je před ním |
execution.started | handler byl vstoupen |
execution.progress | jak daleko, s celkem, když ho handler zná |
execution.step.started, execution.step.completed | byla vstoupena pojmenovaná fáze a jak dopadla |
execution.log | řádek výstupu, z handleru nebo z procesu, který spustil |
execution.diagnostic | strukturovaný nález, odlišný od výsledku |
execution.cancelling, execution.cancelled | byl požádán, aby se zastavil; zastavil se |
execution.completed | skončilo, s tím, co handler vrátil |
execution.failed | skončilo s typovanou chybou a korelačním id |
Výstup putuje na execution.completed místo na vlastní události, takže žádný klient nemůže přečíst „succeeded“ a nenajít výsledek. sequence je souvislé a v rámci provedení začíná od 1: klient, který viděl n, ví, že mu něco uniklo, když další, co čte, není n + 1, a o mezeru si řekne kurzorem, místo aby znovu načítal celý svět.
Schéma se odvozuje z enumu v Rustu pomocí schemars, přesně jako vstupní a výstupní schémata každé schopnosti, a servíruje se — s celým kontraktem — přes executions.protocol. Nic se nezapisuje dvakrát.
Klient musí ignorovat type, které nezná. Díky tomu lze přidat variantu bez změny verze; schema_version se pohne jen při změně, kterou klient verze 1 nemohl bezpečně ignorovat.
Živý kanál
GET /events every execution of this process, live
GET /events?execution=<id> one execution: its retained history, then live
GET /events?execution=<id>&after=<sequence> the same, from a cursor — what a reconnect sends
GET /events?execution=<a>&execution=<b> several executions: each one's history, then live
Nativní WebSocket (RFC 6455), povýšený na stejném socketu, na kterém se servíruje zbytek povrchu: jeden proces, jeden port, jedna startovní cesta, žádný druhý daemon a žádný Socket.IO. Odběr je cíl požadavku, takže není co posílat a není co potvrzovat.
Server zapisuje jeden JSON dokument na každý textový rámec: buď událost provádění, nebo jednu ze čtyř řídicích zpráv — stream.ready (první rámec: co bylo odebíráno, kolik bylo přehráno, zda už byla předchozí historie zahozena, interval heartbeatu), stream.heartbeat (kanál je tichý a živý), stream.lagged (tento klient zaostal a události byly zahozeny místo bufferování; znovu načtěte snapshot a historii od poslední viděné sekvence) a stream.closing.
Pořadí a mezery. Spojení se nejprve přihlásí k odběru a teprve poté přehrává, a v živém streamu přeskakuje vše, co přehrávání už zapsalo. Přehrávání se čte po stránkách po 1000, dokud není dohnáno, takže kurzor zaostávající o víc než jednu stránku stále dostane každou uchovanou událost po něm. Mezi historií a streamem tedy není mezera a nic se nezapisuje dvakrát. Klient Cockpitu (share/cockpit/socket.js) kontroluje obojí na své straně: událost na posledním viděném pořadovém čísle nebo pod ním se zahodí a na kanálu sledujícím jedno provedení událost za dalším číslem způsobí opětovné připojení s after=<last seen>, takže server přehraje, co bylo zmeškáno; táž mezera podruhé se přečte z historie přes HTTP.
Několik provádění, každé se svým kurzorem. sequence je husté pro každé provádění, takže spojení, které jmenuje několik provádění, drží kurzor pro každé a události každého deduplikuje proti jeho vlastnímu. Jeden after spouští každý kurzor: každé jmenované provádění se přehrává stránku po stránce, dokud není dohnáno, od událostí po něm, a provádění jmenované dvakrát se sleduje jednou. stream.ready počítá celé přehrání v replayed a truncated je pravda, když historie kteréhokoli sledovaného provádění už byla zahozena. Klient, který se znovu připojuje k několika prováděním, nepošle žádný kurzor a pro každé provádění zahodí, co už má; share/cockpit/socket.js Cockpitu dělá přesně to. Spojení s jedním prováděním posílá sekvenci, kterou naposledy viděl, jako vždy.
Heartbeat a odpojení. Server pinguje tiché spojení každých dvacet sekund a nikdy nečte: tiny_http při upgradu předá jedinou hodnotu Read + Write, kterou nelze rozdělit, takže by čtecí vlákno drželo zámek, který potřebuje zapisovač. Klient, který zmizí, se pozná, když selže další zápis, což heartbeat zaručuje; co klient pošle, se ignoruje. Uzavření se tedy zjistí do jednoho heartbeatu, ne okamžitě. Ping je řídicí rámec, který prohlížeč stránce nikdy neukáže, takže stejný interval zapisuje i textový rámec stream.heartbeat; klient Cockpitu zahodí kanál, který dva a půl heartbeatu neřekl nic, a znovu se připojí od svého kurzoru, místo aby napůl otevřené spojení ukazoval jako živé.
Hodnota příliš velká pro rámec. Jeden rámec má nejvýše 256 KiB. Událost, jejíž rámec by byl větší — output dokončeného provedení, input vytvořeného — se zapíše s touto hodnotou nahrazenou odkazem, {"withheld": "too_large", "bytes": n, "limit": 262144, "self": "/api/v1/executions/get?id=…"}, a kanál zůstane otevřený; snímek, který jmenuje, nese celou hodnotu.
Zpětný tlak. Odběratel, který zaostane za svou frontou, se nikdy nečeká: události se zahazují jen pro tohoto klienta, je mu to řečeno stream.lagged a provedení nezpomalí nikdo, kdo je sleduje. Odběratel, jehož čtenář zmizel, se zapomene při další události.
Odmítnutí. Prosté GET /events odpoví 426 a řekne, kde jsou tytéž události čitelné přes HTTP. Požadavek z jiného originu se odmítne 403 před handshake — stránka jinde může otevřít WebSocket na loopback server a same-origin policy to nezastaví. Stejně tak upgrade z tohoto stroje adresovaný na jméno hostitele, které není ani adresa, ani localhost: to je stránka, jejíž doména byla donucena překládat se sem (DNS rebinding), odmítnutá před upgradem, jako ji odmítá každá jiná routa. Parametr execution, který není id provedení, je 400, odmítnutý podle tvaru dřív, než se cokoli hledá.
V Cockpitu
/cockpit/executions vypisuje, co tento server spustil a co mu majordomus run nahlásil, s počty vedle; /cockpit/executions/<id> je jedno provedení na stabilní URL, kterou člověk může znovu načíst, sdílet lokálně nebo jich otevřít několik v kartách.
Obě stránky jsou kompletní HTML dříve, než běží jakýkoli skript — stav, kroky, průběh, diagnostika, log, výstup a vstup jsou to, co vykreslil server — a skripty přidávají, že přestávají být fotografií. socket.js je jediné místo v prohlížeči, které otevírá socket: znovu se připojuje s exponenciálním backoffem, stropem a jitterem, znovu se přihlásí od sekvence, při níž byla stránka vykreslena, a ukazuje spojení slovem a tvarem, ne jen barvou. Každou URL, kterou používá, vykreslil do značek server; v prohlížeči není zapsána žádná cesta.
Na stránce samotné schopnosti stojí Spustit jako provádění vedle přímého runneru. Oba používají stejný generovaný formulář a tlačítko žádá o potvrzení tehdy — a jen tehdy — když efekt deskriptoru říká, že se něco mění. Událost, kterou stránka nezná, zobrazí, nezahodí.
Řádek logu se na stránku dostává přes textContent, nikdy ne innerHTML, a server z něj už odstranil řídicí znaky a terminálové escape sekvence. Log se posouvá uvnitř vlastního boxu a je omezen v prohlížeči i na serveru, takže dlouhá cesta v řádku se zalomí a nerozšíří stránku.
Přidání schopnosti, kterou lze sledovat
Nic navíc. Napište blok capability!, jak jej popisuje CAPABILITIES.md, a lze jej spustit jako provádění v okamžiku, kdy existuje. Dvě volitelné věci jej činí lepším:
- Report.
ctx.progressnesestep,step_done,progress,log,streamadiagnostic. Všechna lze volat zdarma mimo provedení, takže handler reportuje bezpodmínečně a píše se jednou pro každého volajícího. - Deklarujte ji jako zrušitelnou —
.cancellable()za blokemcapability!— když handler kontrolujectx.progress.cancelled()a zastaví se. Cockpit pak nabídne tlačítko Zrušit, které něco dělá,executions.cancelto řekne ve své odpovědi a handler, který svůj příznak ignoruje, nikdy nevytvoří ovládací prvek, který lže.
fn verify(ctx: &Context, input: VerifyInput) -> Result<Report, CapabilityError> {
let p = &ctx.progress;
p.step("read", "Reading the layer");
for (n, file) in files.iter().enumerate() {
if p.cancelled() {
return Err(p.cancellation());
}
p.progress(n as u64 + 1, Some(total), format!("read {}", file.path));
}
p.step_done("read", true, None);
Ok(report)
}
Cachovaná schopnost spuštěná jako provedení se spustí. Cache činí jednu odpověď k nerozeznání od druhé, což je správné pro odpověď a špatné pro záznam práce: provedení odpovězené z cache by nehlásilo žádné kroky a tvrdilo by úspěch v něčem, co se nestalo. Context::execute_observed je cesta, kterou engine jde, a přeskakuje cache v obou směrech.
Bezpečnost
Běží jen registrované schopnosti. Rovina bere id schopnosti a vstup; neexistuje žádný endpoint, který by bral příkaz, a vstup, který smí klient poslat, jsou dvě pole, jejichž schéma je v dokumentu OpenAPI. Schopnost, která by jednou spouštěla proces, by byla blokem capability! s vlastním seznamem povolených, přezkoumaným jako každý jiný.
Izolace repozitáře. Proces obsluhuje repozitář, který přečetl při startu, a každé provedení je jím označeno. Nic nemůže spustit schopnost proti jinému checkoutu a nic, co klient pošle, nejmenuje adresář.
Tajemství. Hodnota, kterou vstupní schéma označí jako citlivou — format: "password", writeOnly nebo x-majordomus-sensitive — se nahradí jednou, v enginu, před uložením vstupu, takže ji nemůže nést žádný snímek, událost, historie, stránka, výpis ani generovaný dokument. O tom nikdy nerozhoduje název pole: password_policy může být dokument a redaktor shodující se podle názvu ho zničí, zatímco další pole napsané jinak mine.
Selhání. Handler, který zpanikaří, se zachytí: provedení selže s internal, korelačním id a návrhem a samotná panika jde do logu tohoto procesu, kde ji operátor přečte. Žádný backtrace ani cesta uvnitř tohoto crate se ke klientovi nedostane.
Origin. Živý kanál a každá trasa měnící stav odmítnou požadavek z jiného originu. Content-security policy Cockpitu povoluje spojení jen s tímto originem.
Limity a co se stane u každého
| limit | výchozí | na mezi |
|---|---|---|
| zapamatovaná provedení | 200 | nejstarší dokončené se zapomene; běžící nikdy |
| událostí uchovaných na jedno provádění | 2000 | nejstarší se zahazují a snapshot i každá stránka historie uvádějí truncated |
| řádek logu | 2000 znaků | zkráceno, se značkou |
| provedení běžící současně | 4 | další čekají queued a execution.queued říká, kolik jich je před nimi |
| fronta odběratele | 512 | události se pro tohoto klienta zahazují a klient se dozví stream.lagged |
| živé kanály | 64 | další spojení je odmítnuto 503 s důvodem |
| jeden snímek živého kanálu | 256 KiB | výstup nebo vstup nad ním se zapisuje jako odkaz na snapshot |
| sériová schopnost (příkaz) | po jednom | druhé provádění téhož čeká na první |
| události v jednom reportu z běhu | 500 a tři čtvrtiny meze těla 1 MiB | odmítnuto nad 500; běh pošle zbytek v dalším reportu |
| ticho nahlášeného provedení | 30 s (heartbeat běhu je 2 s) | server ho selže s unavailable; samotný běh se nedotkne |
Výchozí hodnoty jsou pro vývojový server, který člověk sleduje, a executions.protocol odpovídá těmi, se kterými tento proces běží.
Když něco selže
| zpráva | význam | náprava |
|---|---|---|
no capability 'x'; the executable ones are listed by capabilities.list | id není v registru | kontrola majordomus capabilities list |
not executable: 'x' is a resource: it is read, never executed | byl pojmenován deklarativní objekt | čtěte pomocí objects.get |
invalid input: /steps: "many" is not of type "integer" | vstup nesplňuje vlastní schéma schopnosti | schéma je v dokumentu OpenAPI a na stránce schopnosti v Cockpitu |
no execution x-… in this process | tady nikdy neběželo, nebo se na to zapomnělo | provedení žije v serveru, který ho drží; executions.list říká, co se pamatuje |
majordomus run: no server is serving this repository, so this execution is visible to this process only… | run nenašel žádný server odpovídající pro tento checkout | spusťte majordomus serve před během, abyste ho tam viděli; běh žádný nespouští |
majordomus run: the server at … did not take this run's report (…) | server odmítl nebo neodpověděl; starší server nemá executions.report | běh pokračuje; důvod v závorkách je důvodem serveru |
unavailable: process N, which ran this execution and reported it here, has not reported for … s | běh byl zabit nebo ztratil spojení se serverem na 30 s | výsledek má terminál, který ho spustil |
426 … /events is a WebSocket | byl otevřen prostým GET | otevřete ji klientem WebSocket, nebo čtěte executions.events |
403 … a WebSocket from origin '…' is refused | stránka z jiného originu se pokusila sledovat tento server | otevřít Cockpit z vlastní adresy tohoto serveru |
stream.lagged | tento klient nestíhal | znovu načíst snapshot a historii od poslední viděné sekvence |
| provedení, které nehlásí žádné kroky | schopnost žádnou nehlásí | to je fakt o schopnosti, ne o rovině; executions.demonstrate ukazuje, jak vypadá taková, která to dělá |
Stabilita
| status | |
|---|---|
| stavový automat, jeho přechody a odmítnutí neplatného | ověřeno chováním (execution::model, execution::store) |
| meze úložiště a chování u každé: vytěsnění, zkrácení, zpoždění, zmizelý odběratel | behaviorálně ověřeno (execution::store) |
| redakce od schématu, přes reference, pole a mapy | behaviorálně ověřeno (execution::redact) |
| handshake, rámování a SHA-1 proti publikovaným vektorům | behaviorálně ověřeno (http::ws) |
| start, sledování, znovunačtení, znovupřipojení s kurzorem, zrušení, selhání — přes skutečný socket se skutečným klientem WebSocket | behaviorálně ověřeno (tests/executions.rs) |
| existující schopnost pozorovatelná bez druhé implementace | behaviorálně ověřeno (tests/executions.rs) |
| provedení, na které cache nikdy neodpověděla | behaviorálně ověřeno (tests/executions.rs) |
| celá cesta zvenčí spustitelného souboru | behaviorálně ověřeno (test/cases/100_execution_plane.sh) |
| běh hlášený serveru: aplikován jednou a v pořadí, obnoven po mezeře, zastavení vráceno, mlčící reportér označen jako selhaný | behaviorálně ověřeno (execution::store) |
| běh vypsaný serverem, dokud probíhá, stejný výsledek po něm, zastavení vyžádané od serveru, které doputuje k běhu, prostý řádek bez serveru | behaviorálně ověřeno dotazováním serveru (test/cases/3024_a_run_is_seen_by_the_server.sh) |
| trvanlivost, opakování, plánování, provedení přežívající svůj proces | neimplementováno a Limits je místo, kde by se nahradilo trvalé úložiště |
schopnost podložená procesem, jehož výstup jde jako logové události stdout/stderr | neimplementováno; model událostí pro to nese proudy |
| čtení rámců klienta, a tedy okamžité uzavření | neimplementováno; důvod je v http::ws |