@telorun/run 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +233 -0
- package/dist/sequence.d.ts +16 -2
- package/dist/sequence.js +58 -18
- package/package.json +20 -2
- package/src/sequence.ts +98 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# @telorun/run
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 353d7e5: feat: invocable errors — structured error channel end-to-end
|
|
8
|
+
|
|
9
|
+
Invocables and runnables now have a first-class structured-error channel for domain failures (`InvokeError`), distinct from operational failures (plain `Error` / `RuntimeError`). Route handlers branch on named codes via `catches:`; sequences catch with `error.code` / `error.message` / `error.data` / `error.step` context.
|
|
10
|
+
|
|
11
|
+
**SDK** (`@telorun/sdk`)
|
|
12
|
+
|
|
13
|
+
- New `InvokeError` class + `isInvokeError` guard. Symbol-based discrimination (`Symbol.for("telo.InvokeError")`) is dual-realm-safe across pnpm hoist splits, registry modules, and future sandbox isolation.
|
|
14
|
+
- `ResourceDefinition.throws`: declared-throw contract (`codes` map, `inherit: true`, `passthrough: true`).
|
|
15
|
+
- `ResourceContext` / `EvaluationContext` gain `invokeResolved(kind, name, instance, inputs)` for callers that already hold a resolved instance.
|
|
16
|
+
|
|
17
|
+
**Kernel** (`@telorun/kernel`)
|
|
18
|
+
|
|
19
|
+
- Single emission point for invoke-level events: `Invoked` / `InvokeRejected` / `InvokeFailed` / `InvokeRejected.Undeclared`. All call paths (direct invoke, sequence scope path, HTTP route handler) route through the same wrapper.
|
|
20
|
+
- `Telo.Definition.throws:` schema with per-capability restrictions (rule 8: only on Invocable / Runnable).
|
|
21
|
+
- `resolveChildren` now auto-registers bare-kind inline refs when a resource name is supplied without an explicit name on the ref — lets stateless invocables like `Run.Throw` be used inline via `invoke: {kind: Run.Throw}`.
|
|
22
|
+
|
|
23
|
+
**Analyzer** (`@telorun/analyzer`)
|
|
24
|
+
|
|
25
|
+
- New dataflow resolver (`resolve-throws-union.ts`) for `inherit: true` / `passthrough: true` declarations. Walks `x-telo-step-context` arrays generically, applies `try`/`catch` subtraction, detects cycles, memoises per manifest.
|
|
26
|
+
- New coverage validator (`validate-throws-coverage.ts`) — rules 1/2/4/7 for `catches:` lists. Coverage-proving CEL parser recognises `error.code == 'X'`, disjunctions, and `error.code in [...]`. Typed `error.data.<field>` access against per-code `data:` schemas, with intersection narrowing for disjunctive `when:` clauses.
|
|
27
|
+
- New error codes: `UNDECLARED_THROW_CODE`, `UNCOVERED_THROW_CODE`, `UNBOUNDED_UNION_NEEDS_CATCHALL`, `CATCHALL_NOT_LAST`, `INHERIT_WITHOUT_STEP_CONTEXT`.
|
|
28
|
+
|
|
29
|
+
**Run module** (`@telorun/run`)
|
|
30
|
+
|
|
31
|
+
- `Run.Sequence` declares `throws: { inherit: true }`. Its effective union is resolved from step invocables at analysis time.
|
|
32
|
+
- New `Run.Throw` invocable: takes `{code, message, data?}` and throws `InvokeError`. Declared with `throws: { passthrough: true }`; the analyzer resolves constant / `error.code`-inside-catch forms at each call site.
|
|
33
|
+
- Sequence `try`/`catch` `error` context gains `data?: unknown` and now branches on `isInvokeError`.
|
|
34
|
+
|
|
35
|
+
**HTTP server module** (`@telorun/http-server`) — **breaking**
|
|
36
|
+
|
|
37
|
+
- Route-level `response:` is replaced by two channel lists: `returns:` (how to render handler results) and `catches:` (how to render `InvokeError` throws). Applies to both `Http.Api` routes and `Http.Server.notFoundHandler`.
|
|
38
|
+
- Plain `Error` / `RuntimeError` throws skip `catches:` and fall through to Fastify's default 5xx renderer — operational vs. domain failures are now distinct on the wire.
|
|
39
|
+
- `catches:` entries reject `mode: stream` at schema validation (structured errors always render as JSON).
|
|
40
|
+
- Unmatched `returns:` dispatch now throws (surfaces via Fastify's error handler) instead of rendering a silent 500.
|
|
41
|
+
- Every `response:` occurrence across the repo (apps, benchmarks, examples, tests) migrated to `returns:` — no manifest carries the old shape.
|
|
42
|
+
|
|
43
|
+
See `sdk/nodejs/plans/invocable-errors.md` for the full design and rollout phasing.
|
|
44
|
+
|
|
45
|
+
- 31d721e: feat: bearer-token auth for the Telo module registry publish endpoint
|
|
46
|
+
|
|
47
|
+
The registry's `PUT /{namespace}/{name}/{version}` now requires an `Authorization: Bearer <token>` header. Reads stay anonymous. Tokens are provisioned declaratively at boot via `TELO_PUBLISH_TOKEN` and stored as SHA-256 hashes in a `tokens` table joined to `users` and `namespaces`.
|
|
48
|
+
|
|
49
|
+
**Analyzer** (`@telorun/analyzer`) — **breaking for direct API consumers**
|
|
50
|
+
|
|
51
|
+
- `StaticAnalyzer` and `Loader` now accept an optional `{ celHandlers }` in their constructors. Analyzer-only callers (VS Code extension, Docusaurus preview, CLI `check`/`publish`) can omit it and get throwing stubs. Runtime callers (kernel) must supply real handlers.
|
|
52
|
+
- The module-level `celEnvironment` singleton is removed — `precompile.ts` now takes the `Environment` as a parameter.
|
|
53
|
+
- New CEL stdlib function: `sha256(string): string`. Always registered with the correct signature so `env.check()` type-checks; behaviour depends on the supplied handler.
|
|
54
|
+
- The throws-union resolver recognises the new `throw:` step shape (see Run module) and resolves its code at the call site using the same rules as passthrough invocables (literal / `${{ 'LIT' }}` / `${{ error.code }}` in catch).
|
|
55
|
+
- CEL type-check failures now surface as diagnostics. Previously the analyzer only reported schema/type mismatches on valid expressions; `env.check(...)` returning `{ valid: false }` (wrong method, wrong operand types, wrong overload — e.g. `s.slice(7)` on a dyn) was silently dropped. Now surfaces as `SCHEMA_VIOLATION` with a `CEL type error:` message.
|
|
56
|
+
|
|
57
|
+
**Kernel** (`@telorun/kernel`)
|
|
58
|
+
|
|
59
|
+
- Constructs `StaticAnalyzer` and `Loader` with a `node:crypto`-backed `sha256` handler, so CEL templates invoking `sha256()` evaluate at runtime.
|
|
60
|
+
|
|
61
|
+
**Run module** (`@telorun/run`) — **breaking**
|
|
62
|
+
|
|
63
|
+
- `Run.Sequence` gains a first-class `throw:` step variant: `- name: X; throw: { code, message?, data? }` — throws `InvokeError` directly from inside the sequence. Works inside `catch:` blocks via `code: "${{ error.code }}"` for re-raise. A malformed `throw.code` (non-string or empty after expansion) is itself reported as `InvokeError("INVALID_THROW_STEP", …)` rather than a plain Error, so the failure stays in the structured-error channel and a surrounding `catches:` can map it.
|
|
64
|
+
- The `Run.Throw` invocable is removed. Existing `invoke: { kind: Run.Throw }` call sites must migrate to `throw:` steps. The separate kind was redundant with the new step form, and the `throw:` step expresses the intent more directly inside sequences.
|
|
65
|
+
- **Event-stream change:** `throw:` steps do **not** emit a scoped `<Kind>.<name>.InvokeRejected` event the way `Run.Throw` did. The error is thrown from inside the sequence's own `invoke()`, so the enclosing kind's event is what fires (e.g. `Run.Sequence.<handlerName>.InvokeRejected` — or nothing, when an enclosing `try` absorbs the throw). Downstream observers that filtered on `Run.Throw.*.InvokeRejected` must switch filters.
|
|
66
|
+
|
|
67
|
+
**CLI** (`@telorun/cli`)
|
|
68
|
+
|
|
69
|
+
- `telo publish` reads `TELO_REGISTRY_TOKEN` and sends it as `Authorization: Bearer <token>`. Without the env var, publishes to auth-gated registries fail with 401.
|
|
70
|
+
|
|
71
|
+
See `apps/registry/plans/registry-auth.md` for the full plan.
|
|
72
|
+
|
|
73
|
+
### Patch Changes
|
|
74
|
+
|
|
75
|
+
- Updated dependencies [353d7e5]
|
|
76
|
+
- @telorun/sdk@0.3.0
|
|
77
|
+
|
|
78
|
+
## 0.1.3
|
|
79
|
+
|
|
80
|
+
### Patch Changes
|
|
81
|
+
|
|
82
|
+
- Updated dependencies
|
|
83
|
+
- @telorun/sdk@0.2.8
|
|
84
|
+
|
|
3
85
|
## 0.1.2
|
|
4
86
|
|
|
5
87
|
### Patch Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# ⚡ Telo
|
|
2
|
+
|
|
3
|
+
Runtime for declarative backends.
|
|
4
|
+
|
|
5
|
+
Telo is an execution engine (Micro-Kernel) that runs logic defined entirely in YAML manifests. Instead of writing imperative backend code, you define your routes, databases, schemas, and AI workflows as atomic, interconnected YAML documents. Telo takes those manifests and runs them.
|
|
6
|
+
|
|
7
|
+
Built to be language-agnostic and infinitely extensible.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Reconcile your manifest into a running backend
|
|
11
|
+
$ telo ./examples/hello-api.yaml
|
|
12
|
+
|
|
13
|
+
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:8844"}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why use Telo?
|
|
17
|
+
|
|
18
|
+
- **Open Standards:** Built on YAML, JSON Schema, and CEL — no proprietary DSL.
|
|
19
|
+
- **Static Analysis:** CEL type checking, reference validation, and IDE diagnostics catch errors before runtime.
|
|
20
|
+
- **Micro-Kernel Architecture:** Telo itself knows nothing about HTTP or SQL. Everything is a module you import, scope, and compose with typed variable and secret contracts.
|
|
21
|
+
- **Language Agnostic:** Available as a Node.js runtime today, with a shared YAML runtime contract that allows for future Rust or Go implementations without changing your manifests.
|
|
22
|
+
|
|
23
|
+
## What It Does
|
|
24
|
+
|
|
25
|
+
- **Loads** YAML resources and compiles CEL expressions (`${{ }}`) into an in-memory registry.
|
|
26
|
+
- **Resolves** resource dependencies via a multi-pass init loop, handling ordering automatically.
|
|
27
|
+
- **Indexes** resources by Kind and Name for constant-time lookup.
|
|
28
|
+
- **Dispatches** execution to the controller that owns each Kind.
|
|
29
|
+
|
|
30
|
+
Manifests also support directives for dynamic generation: `$let`, `$if`, `$for`, `$eval`, and `$include`. See [CEL-YAML Templating](./yaml-cel-templating/README.md) for documentation.
|
|
31
|
+
|
|
32
|
+
## Example manifest
|
|
33
|
+
|
|
34
|
+
Here is an example Telo application that defines a simple HTTP API:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
kind: Telo.Application
|
|
38
|
+
metadata:
|
|
39
|
+
name: feedback
|
|
40
|
+
version: 1.0.0
|
|
41
|
+
description: |
|
|
42
|
+
A complete feedback collection REST API — no code, pure YAML.
|
|
43
|
+
Persists entries to SQLite and serves them over HTTP.
|
|
44
|
+
targets:
|
|
45
|
+
- Migrations
|
|
46
|
+
- Server
|
|
47
|
+
---
|
|
48
|
+
kind: Telo.Import
|
|
49
|
+
metadata:
|
|
50
|
+
name: Http
|
|
51
|
+
source: ../modules/http-server
|
|
52
|
+
---
|
|
53
|
+
kind: Telo.Import
|
|
54
|
+
metadata:
|
|
55
|
+
name: Sql
|
|
56
|
+
source: ../modules/sql
|
|
57
|
+
---
|
|
58
|
+
# SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
|
|
59
|
+
kind: Sql.Connection
|
|
60
|
+
metadata:
|
|
61
|
+
name: Db
|
|
62
|
+
driver: sqlite
|
|
63
|
+
file: ./tmp/feedback.db
|
|
64
|
+
---
|
|
65
|
+
# Migrations: applied automatically before the server starts
|
|
66
|
+
kind: Sql.Migrations
|
|
67
|
+
metadata:
|
|
68
|
+
name: Migrations
|
|
69
|
+
connection:
|
|
70
|
+
kind: Sql.Connection
|
|
71
|
+
name: Db
|
|
72
|
+
---
|
|
73
|
+
kind: Sql.Migration
|
|
74
|
+
metadata:
|
|
75
|
+
name: Migration_20260413_182154_CreateFeedback
|
|
76
|
+
sql: |
|
|
77
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
text TEXT NOT NULL,
|
|
80
|
+
source TEXT,
|
|
81
|
+
score INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
83
|
+
)
|
|
84
|
+
---
|
|
85
|
+
kind: Http.Server
|
|
86
|
+
metadata:
|
|
87
|
+
name: Server
|
|
88
|
+
baseUrl: http://localhost:8844
|
|
89
|
+
port: 8844
|
|
90
|
+
logger: true
|
|
91
|
+
openapi:
|
|
92
|
+
info:
|
|
93
|
+
title: Feedback API
|
|
94
|
+
version: 1.0.0
|
|
95
|
+
mounts:
|
|
96
|
+
- path: /v1
|
|
97
|
+
type: Http.Api.FeedbackRoutes
|
|
98
|
+
---
|
|
99
|
+
kind: Http.Api
|
|
100
|
+
metadata:
|
|
101
|
+
name: FeedbackRoutes
|
|
102
|
+
routes:
|
|
103
|
+
# POST /v1/feedback — insert a new entry, score derived from body length heuristic
|
|
104
|
+
- request:
|
|
105
|
+
path: /feedback
|
|
106
|
+
method: POST
|
|
107
|
+
schema:
|
|
108
|
+
body:
|
|
109
|
+
type: object
|
|
110
|
+
properties:
|
|
111
|
+
text:
|
|
112
|
+
type: string
|
|
113
|
+
minLength: 1
|
|
114
|
+
source:
|
|
115
|
+
type: string
|
|
116
|
+
required: [text]
|
|
117
|
+
handler:
|
|
118
|
+
kind: Sql.Exec
|
|
119
|
+
connection:
|
|
120
|
+
kind: Sql.Connection
|
|
121
|
+
name: Db
|
|
122
|
+
inputs:
|
|
123
|
+
sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
|
|
124
|
+
bindings:
|
|
125
|
+
- "${{ request.body.text }}"
|
|
126
|
+
- "${{ request.body.source }}"
|
|
127
|
+
- "${{ size(request.body.text) }}"
|
|
128
|
+
response:
|
|
129
|
+
- status: 201
|
|
130
|
+
headers:
|
|
131
|
+
Content-Type: application/json
|
|
132
|
+
body:
|
|
133
|
+
ok: true
|
|
134
|
+
message: Feedback received
|
|
135
|
+
|
|
136
|
+
# GET /v1/feedback — list all entries, newest first
|
|
137
|
+
- request:
|
|
138
|
+
path: /feedback
|
|
139
|
+
method: GET
|
|
140
|
+
handler:
|
|
141
|
+
kind: Sql.Select
|
|
142
|
+
connection:
|
|
143
|
+
kind: Sql.Connection
|
|
144
|
+
name: Db
|
|
145
|
+
from: feedback
|
|
146
|
+
columns: [id, text, source, score, created_at]
|
|
147
|
+
orderBy:
|
|
148
|
+
- { column: created_at, direction: desc }
|
|
149
|
+
response:
|
|
150
|
+
- status: 200
|
|
151
|
+
headers:
|
|
152
|
+
Content-Type: application/json
|
|
153
|
+
body: "${{ result.rows }}"
|
|
154
|
+
|
|
155
|
+
# GET /v1/feedback/{id} — fetch a single entry
|
|
156
|
+
- request:
|
|
157
|
+
path: /feedback/{id}
|
|
158
|
+
method: GET
|
|
159
|
+
schema:
|
|
160
|
+
params:
|
|
161
|
+
type: object
|
|
162
|
+
properties:
|
|
163
|
+
id:
|
|
164
|
+
type: integer
|
|
165
|
+
required: [id]
|
|
166
|
+
handler:
|
|
167
|
+
kind: Sql.Select
|
|
168
|
+
connection:
|
|
169
|
+
kind: Sql.Connection
|
|
170
|
+
name: Db
|
|
171
|
+
from: feedback
|
|
172
|
+
columns: [id, text, source, score, created_at]
|
|
173
|
+
where:
|
|
174
|
+
- { column: id, op: "=", value: "${{ request.params.id }}" }
|
|
175
|
+
response:
|
|
176
|
+
- status: 200
|
|
177
|
+
when: "size(result.rows) > 0"
|
|
178
|
+
headers:
|
|
179
|
+
Content-Type: application/json
|
|
180
|
+
body: "${{ result.rows[0] }}"
|
|
181
|
+
- status: 404
|
|
182
|
+
headers:
|
|
183
|
+
Content-Type: application/json
|
|
184
|
+
body:
|
|
185
|
+
ok: false
|
|
186
|
+
message: Not found
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Status
|
|
190
|
+
|
|
191
|
+
Telo is under **active development**. The core runtime, module system, and standard library are functional, but the API surface — including YAML shapes — may change without notice. Not yet recommended for production use.
|
|
192
|
+
|
|
193
|
+
## The Meaning of Telo
|
|
194
|
+
|
|
195
|
+
The name Telo is derived from the Greek root Telos - meaning the "end goal", "purpose", or "final state". That is exactly the philosophy behind this runtime. In standard imperative programming, you have to write thousands of lines of code to tell a server exactly how to start. With Telo, you simply declare your desired final state.
|
|
196
|
+
|
|
197
|
+
You define the end state. Telo makes it real.
|
|
198
|
+
|
|
199
|
+
## Philosophy
|
|
200
|
+
|
|
201
|
+
Modern platforms often spend disproportionate effort on technical mechanics-wiring frameworks, managing infrastructure, and negotiating toolchains-while the original business problem gets delayed or diluted. Telo pushes in the opposite direction: it treats kernel execution as a stable, predictable host so teams can concentrate on the **business logic and outcomes** instead of the plumbing.
|
|
202
|
+
|
|
203
|
+
By separating "what the system should do" from "how it is hosted", the runtime reduces friction for domain‑level changes. Teams can move faster on product requirements, experiment more safely, and keep conversations centered on value delivered rather than implementation trivia.
|
|
204
|
+
|
|
205
|
+
Telo also aims to **join forces across all programming language communities**, so the best ideas, patterns, and implementations can converge into a shared kernel truth without forcing everyone into a single stack.
|
|
206
|
+
|
|
207
|
+
YAML also makes the system more **AI‑friendly** than traditional programming languages: it is explicit, structured, and easier for tools to generate, review, and transform without losing intent.
|
|
208
|
+
|
|
209
|
+
## Modularity
|
|
210
|
+
|
|
211
|
+
Telo is built around **modules** that own specific resource kinds. A module is loaded from a manifest, declares which kinds it implements, and then receives only the resources of those kinds. This keeps concerns isolated and lets teams compose systems from focused building blocks rather than monolithic services.
|
|
212
|
+
|
|
213
|
+
At kernel execution time, execution is always routed by **Kind.Name**. The kernel resolves the Kind to its owning module and hands off execution. Modules can call back into the kernel to execute other resources, enabling composition without tight coupling.
|
|
214
|
+
|
|
215
|
+
## Architecture
|
|
216
|
+
|
|
217
|
+
The architecture is inspired by Kubernetes-style manifests: declarative resources, explicit kinds, and a control plane that routes work based on those definitions.
|
|
218
|
+
Those manifests were taken to the next level by allowing them to run inside a standalone runtime host.
|
|
219
|
+
|
|
220
|
+
## See more at
|
|
221
|
+
|
|
222
|
+
- [Telo Kernel](./kernel/README.md)
|
|
223
|
+
- [CEL-YAML Templating](./yaml-cel-templating/README.md)
|
|
224
|
+
- [Telo SDK for module authors](sdk/README.md)
|
|
225
|
+
- [Modules](modules/README.md)
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
See [LICENSE](https://github.com/telorun/telo/blob/main/LICENSE).
|
|
230
|
+
|
|
231
|
+
## Contribution Note
|
|
232
|
+
|
|
233
|
+
By contributing, you agree that code and examples in this repository may be translated or re‑implemented in other programming languages (including by AI systems) to support the project’s polyglot goals.
|
package/dist/sequence.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Invocable, type KindRef, type ResourceContext, type ScopeHandle } from "@telorun/sdk";
|
|
2
2
|
interface RetryPolicy {
|
|
3
3
|
attempts?: number;
|
|
4
4
|
delay?: string;
|
|
@@ -14,6 +14,10 @@ interface IfStep {
|
|
|
14
14
|
name: string;
|
|
15
15
|
if: string;
|
|
16
16
|
then: Step[];
|
|
17
|
+
elseif?: Array<{
|
|
18
|
+
if: string;
|
|
19
|
+
then: Step[];
|
|
20
|
+
}>;
|
|
17
21
|
else?: Step[];
|
|
18
22
|
}
|
|
19
23
|
interface WhileStep {
|
|
@@ -34,7 +38,15 @@ interface TryStep {
|
|
|
34
38
|
catch?: Step[];
|
|
35
39
|
finally?: Step[];
|
|
36
40
|
}
|
|
37
|
-
|
|
41
|
+
interface ThrowStep {
|
|
42
|
+
name: string;
|
|
43
|
+
throw: {
|
|
44
|
+
code: string;
|
|
45
|
+
message?: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
type Step = InvokeStep | IfStep | WhileStep | SwitchStep | TryStep | ThrowStep;
|
|
38
50
|
interface RunSequenceManifest {
|
|
39
51
|
metadata: Record<string, string | number | boolean>;
|
|
40
52
|
with?: ScopeHandle;
|
|
@@ -49,6 +61,7 @@ declare class RunSequence {
|
|
|
49
61
|
constructor(ctx: ResourceContext, resource: RunSequenceManifest);
|
|
50
62
|
init(): Promise<void>;
|
|
51
63
|
private resolveInvokes;
|
|
64
|
+
private inlineInvokeResourceName;
|
|
52
65
|
run(): Promise<void>;
|
|
53
66
|
invoke(inputs: Record<string, unknown>): Promise<unknown>;
|
|
54
67
|
private runScopeTargets;
|
|
@@ -59,6 +72,7 @@ declare class RunSequence {
|
|
|
59
72
|
private executeIfStep;
|
|
60
73
|
private executeWhileStep;
|
|
61
74
|
private executeSwitchStep;
|
|
75
|
+
private executeThrowStep;
|
|
62
76
|
private executeTryStep;
|
|
63
77
|
}
|
|
64
78
|
export declare function register(): void;
|
package/dist/sequence.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InvokeError, isInvokeError, } from "@telorun/sdk";
|
|
1
2
|
function isInvokeStep(step) {
|
|
2
3
|
return "invoke" in step;
|
|
3
4
|
}
|
|
@@ -13,6 +14,9 @@ function isSwitchStep(step) {
|
|
|
13
14
|
function isTryStep(step) {
|
|
14
15
|
return "try" in step;
|
|
15
16
|
}
|
|
17
|
+
function isThrowStep(step) {
|
|
18
|
+
return "throw" in step;
|
|
19
|
+
}
|
|
16
20
|
class RunSequence {
|
|
17
21
|
ctx;
|
|
18
22
|
resource;
|
|
@@ -23,36 +27,47 @@ class RunSequence {
|
|
|
23
27
|
async init() {
|
|
24
28
|
this.resolveInvokes(this.resource.steps);
|
|
25
29
|
}
|
|
26
|
-
resolveInvokes(stepList) {
|
|
27
|
-
for (const step of stepList) {
|
|
30
|
+
resolveInvokes(stepList, path = ["steps"]) {
|
|
31
|
+
for (const [index, step] of stepList.entries()) {
|
|
32
|
+
const stepPath = [...path, String(index)];
|
|
28
33
|
if (isInvokeStep(step)) {
|
|
29
34
|
const raw = step.invoke;
|
|
30
35
|
if (!raw || typeof raw.invoke !== "function") {
|
|
31
|
-
step.invoke = this.ctx.resolveChildren(raw, step.name);
|
|
36
|
+
step.invoke = this.ctx.resolveChildren(raw, this.inlineInvokeResourceName(step.name, stepPath));
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
if (isIfStep(step)) {
|
|
35
|
-
this.resolveInvokes(step.then);
|
|
40
|
+
this.resolveInvokes(step.then, [...stepPath, "then"]);
|
|
41
|
+
if (step.elseif) {
|
|
42
|
+
for (const [elseifIndex, branch] of step.elseif.entries()) {
|
|
43
|
+
this.resolveInvokes(branch.then, [...stepPath, "elseif", String(elseifIndex), "then"]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
36
46
|
if (step.else)
|
|
37
|
-
this.resolveInvokes(step.else);
|
|
47
|
+
this.resolveInvokes(step.else, [...stepPath, "else"]);
|
|
38
48
|
}
|
|
39
49
|
if (isWhileStep(step))
|
|
40
|
-
this.resolveInvokes(step.do);
|
|
50
|
+
this.resolveInvokes(step.do, [...stepPath, "do"]);
|
|
41
51
|
if (isSwitchStep(step)) {
|
|
42
|
-
for (const branch of Object.
|
|
43
|
-
this.resolveInvokes(branch);
|
|
52
|
+
for (const [caseName, branch] of Object.entries(step.cases)) {
|
|
53
|
+
this.resolveInvokes(branch, [...stepPath, "cases", caseName]);
|
|
54
|
+
}
|
|
44
55
|
if (step.default)
|
|
45
|
-
this.resolveInvokes(step.default);
|
|
56
|
+
this.resolveInvokes(step.default, [...stepPath, "default"]);
|
|
46
57
|
}
|
|
47
58
|
if (isTryStep(step)) {
|
|
48
|
-
this.resolveInvokes(step.try);
|
|
59
|
+
this.resolveInvokes(step.try, [...stepPath, "try"]);
|
|
49
60
|
if (step.catch)
|
|
50
|
-
this.resolveInvokes(step.catch);
|
|
61
|
+
this.resolveInvokes(step.catch, [...stepPath, "catch"]);
|
|
51
62
|
if (step.finally)
|
|
52
|
-
this.resolveInvokes(step.finally);
|
|
63
|
+
this.resolveInvokes(step.finally, [...stepPath, "finally"]);
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
}
|
|
67
|
+
inlineInvokeResourceName(stepName, stepPath) {
|
|
68
|
+
const safeName = stepName.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
69
|
+
return `__sequence_${stepPath.join("_")}__${safeName}`;
|
|
70
|
+
}
|
|
56
71
|
async run() {
|
|
57
72
|
if (this.resource.with) {
|
|
58
73
|
await this.resource.with.run(async (scope) => {
|
|
@@ -109,6 +124,8 @@ class RunSequence {
|
|
|
109
124
|
await this.executeSwitchStep(step, steps, scope, extraCtx);
|
|
110
125
|
else if (isTryStep(step))
|
|
111
126
|
await this.executeTryStep(step, steps, scope, extraCtx);
|
|
127
|
+
else if (isThrowStep(step))
|
|
128
|
+
this.executeThrowStep(step, steps, extraCtx);
|
|
112
129
|
else
|
|
113
130
|
throw new Error(`Step "${step.name}" has no recognized type key`);
|
|
114
131
|
}
|
|
@@ -125,7 +142,8 @@ class RunSequence {
|
|
|
125
142
|
else {
|
|
126
143
|
const ref = raw;
|
|
127
144
|
if (scope) {
|
|
128
|
-
|
|
145
|
+
const instance = scope.getInstance(ref.name);
|
|
146
|
+
result = await this.ctx.invokeResolved(ref.kind, ref.name, instance, inputs);
|
|
129
147
|
}
|
|
130
148
|
else {
|
|
131
149
|
result = await this.ctx.invoke(ref.kind, ref.name, inputs, { retry: step.retry });
|
|
@@ -136,8 +154,17 @@ class RunSequence {
|
|
|
136
154
|
async executeIfStep(step, steps, scope, extraCtx) {
|
|
137
155
|
if (this.ctx.expandValue(step.if, { steps, ...extraCtx })) {
|
|
138
156
|
await this.executeSteps(step.then, steps, scope, extraCtx);
|
|
157
|
+
return;
|
|
139
158
|
}
|
|
140
|
-
|
|
159
|
+
if (step.elseif) {
|
|
160
|
+
for (const branch of step.elseif) {
|
|
161
|
+
if (this.ctx.expandValue(branch.if, { steps, ...extraCtx })) {
|
|
162
|
+
await this.executeSteps(branch.then, steps, scope, extraCtx);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (step.else) {
|
|
141
168
|
await this.executeSteps(step.else, steps, scope, extraCtx);
|
|
142
169
|
}
|
|
143
170
|
}
|
|
@@ -158,6 +185,19 @@ class RunSequence {
|
|
|
158
185
|
throw new Error(`Switch step "${step.name}": no matching case for "${key}" and no default`);
|
|
159
186
|
}
|
|
160
187
|
}
|
|
188
|
+
executeThrowStep(step, steps, extraCtx) {
|
|
189
|
+
const cel = { steps, ...extraCtx };
|
|
190
|
+
const expanded = this.ctx.expandValue(step.throw, cel);
|
|
191
|
+
const code = expanded?.code;
|
|
192
|
+
if (typeof code !== "string" || code.length === 0) {
|
|
193
|
+
// Structured error (not plain Error) so the failure stays in the InvokeError
|
|
194
|
+
// channel and a route's `catches:` list can still map it. The alternative —
|
|
195
|
+
// a plain Error — would skip catches: entirely and fall through to a 500.
|
|
196
|
+
throw new InvokeError("INVALID_THROW_STEP", `Run.Sequence step "${step.name}": throw.code is required and must resolve to a non-empty string`, { step: step.name, code });
|
|
197
|
+
}
|
|
198
|
+
const message = typeof expanded.message === "string" ? expanded.message : code;
|
|
199
|
+
throw new InvokeError(code, message, expanded.data);
|
|
200
|
+
}
|
|
161
201
|
async executeTryStep(step, steps, scope, extraCtx) {
|
|
162
202
|
if (step.when !== undefined && !this.ctx.expandValue(step.when, { steps, ...extraCtx }))
|
|
163
203
|
return;
|
|
@@ -205,11 +245,11 @@ class RunSequence {
|
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
247
|
function toSequenceError(err, stepName) {
|
|
248
|
+
if (isInvokeError(err)) {
|
|
249
|
+
return { message: err.message, code: err.code, data: err.data, step: stepName };
|
|
250
|
+
}
|
|
208
251
|
const message = err instanceof Error ? err.message : String(err);
|
|
209
|
-
|
|
210
|
-
? err.code
|
|
211
|
-
: null;
|
|
212
|
-
return { message, code, step: stepName };
|
|
252
|
+
return { message, code: null, data: undefined, step: stepName };
|
|
213
253
|
}
|
|
214
254
|
export function register() { }
|
|
215
255
|
export async function create(resource, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/run",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Telo Run module - Sequence execution for Telo manifests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"run",
|
|
8
|
+
"sequence",
|
|
9
|
+
"pipeline"
|
|
10
|
+
],
|
|
11
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
12
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
16
|
+
"directory": "modules/run/nodejs"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
21
|
+
},
|
|
4
22
|
"type": "module",
|
|
5
23
|
"main": "./src/sequence.ts",
|
|
6
24
|
"module": "./src/sequence.ts",
|
|
@@ -11,7 +29,7 @@
|
|
|
11
29
|
}
|
|
12
30
|
},
|
|
13
31
|
"dependencies": {
|
|
14
|
-
"@telorun/sdk": "0.
|
|
32
|
+
"@telorun/sdk": "0.3.0"
|
|
15
33
|
},
|
|
16
34
|
"devDependencies": {
|
|
17
35
|
"@types/node": "^20.0.0",
|
package/src/sequence.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
InvokeError,
|
|
3
|
+
isInvokeError,
|
|
4
|
+
type Invocable,
|
|
5
|
+
type KindRef,
|
|
6
|
+
type ResourceContext,
|
|
7
|
+
type ResourceInstance,
|
|
8
|
+
type ScopeContext,
|
|
9
|
+
type ScopeHandle,
|
|
10
|
+
} from "@telorun/sdk";
|
|
2
11
|
|
|
3
12
|
interface RetryPolicy {
|
|
4
13
|
attempts?: number;
|
|
@@ -17,6 +26,10 @@ interface IfStep {
|
|
|
17
26
|
name: string;
|
|
18
27
|
if: string;
|
|
19
28
|
then: Step[];
|
|
29
|
+
elseif?: Array<{
|
|
30
|
+
if: string;
|
|
31
|
+
then: Step[];
|
|
32
|
+
}>;
|
|
20
33
|
else?: Step[];
|
|
21
34
|
}
|
|
22
35
|
|
|
@@ -41,7 +54,16 @@ interface TryStep {
|
|
|
41
54
|
finally?: Step[];
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
interface ThrowStep {
|
|
58
|
+
name: string;
|
|
59
|
+
throw: {
|
|
60
|
+
code: string;
|
|
61
|
+
message?: string;
|
|
62
|
+
data?: unknown;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Step = InvokeStep | IfStep | WhileStep | SwitchStep | TryStep | ThrowStep;
|
|
45
67
|
|
|
46
68
|
interface RunSequenceManifest {
|
|
47
69
|
metadata: Record<string, string | number | boolean>;
|
|
@@ -55,6 +77,7 @@ interface RunSequenceManifest {
|
|
|
55
77
|
interface SequenceError {
|
|
56
78
|
message: string;
|
|
57
79
|
code: string | null;
|
|
80
|
+
data?: unknown;
|
|
58
81
|
step: string;
|
|
59
82
|
}
|
|
60
83
|
|
|
@@ -73,6 +96,9 @@ function isSwitchStep(step: Step): step is SwitchStep {
|
|
|
73
96
|
function isTryStep(step: Step): step is TryStep {
|
|
74
97
|
return "try" in step;
|
|
75
98
|
}
|
|
99
|
+
function isThrowStep(step: Step): step is ThrowStep {
|
|
100
|
+
return "throw" in step;
|
|
101
|
+
}
|
|
76
102
|
|
|
77
103
|
class RunSequence {
|
|
78
104
|
constructor(
|
|
@@ -84,34 +110,47 @@ class RunSequence {
|
|
|
84
110
|
this.resolveInvokes(this.resource.steps);
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
private resolveInvokes(stepList: Step[]): void {
|
|
88
|
-
for (const step of stepList) {
|
|
113
|
+
private resolveInvokes(stepList: Step[], path: string[] = ["steps"]): void {
|
|
114
|
+
for (const [index, step] of stepList.entries()) {
|
|
115
|
+
const stepPath = [...path, String(index)];
|
|
89
116
|
if (isInvokeStep(step)) {
|
|
90
117
|
const raw = step.invoke as unknown;
|
|
91
118
|
if (!raw || typeof (raw as Invocable).invoke !== "function") {
|
|
92
119
|
(step as InvokeStep).invoke = this.ctx.resolveChildren(
|
|
93
120
|
raw as any,
|
|
94
|
-
step.name,
|
|
121
|
+
this.inlineInvokeResourceName(step.name, stepPath),
|
|
95
122
|
) as KindRef<Invocable>;
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
if (isIfStep(step)) {
|
|
99
|
-
this.resolveInvokes(step.then);
|
|
100
|
-
if (step.
|
|
126
|
+
this.resolveInvokes(step.then, [...stepPath, "then"]);
|
|
127
|
+
if (step.elseif) {
|
|
128
|
+
for (const [elseifIndex, branch] of step.elseif.entries()) {
|
|
129
|
+
this.resolveInvokes(branch.then, [...stepPath, "elseif", String(elseifIndex), "then"]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (step.else) this.resolveInvokes(step.else, [...stepPath, "else"]);
|
|
101
133
|
}
|
|
102
|
-
if (isWhileStep(step)) this.resolveInvokes(step.do);
|
|
134
|
+
if (isWhileStep(step)) this.resolveInvokes(step.do, [...stepPath, "do"]);
|
|
103
135
|
if (isSwitchStep(step)) {
|
|
104
|
-
for (const branch of Object.
|
|
105
|
-
|
|
136
|
+
for (const [caseName, branch] of Object.entries(step.cases)) {
|
|
137
|
+
this.resolveInvokes(branch, [...stepPath, "cases", caseName]);
|
|
138
|
+
}
|
|
139
|
+
if (step.default) this.resolveInvokes(step.default, [...stepPath, "default"]);
|
|
106
140
|
}
|
|
107
141
|
if (isTryStep(step)) {
|
|
108
|
-
this.resolveInvokes(step.try);
|
|
109
|
-
if (step.catch) this.resolveInvokes(step.catch);
|
|
110
|
-
if (step.finally) this.resolveInvokes(step.finally);
|
|
142
|
+
this.resolveInvokes(step.try, [...stepPath, "try"]);
|
|
143
|
+
if (step.catch) this.resolveInvokes(step.catch, [...stepPath, "catch"]);
|
|
144
|
+
if (step.finally) this.resolveInvokes(step.finally, [...stepPath, "finally"]);
|
|
111
145
|
}
|
|
112
146
|
}
|
|
113
147
|
}
|
|
114
148
|
|
|
149
|
+
private inlineInvokeResourceName(stepName: string, stepPath: string[]): string {
|
|
150
|
+
const safeName = stepName.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
151
|
+
return `__sequence_${stepPath.join("_")}__${safeName}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
async run(): Promise<void> {
|
|
116
155
|
if (this.resource.with) {
|
|
117
156
|
await this.resource.with.run(async (scope) => {
|
|
@@ -179,6 +218,7 @@ class RunSequence {
|
|
|
179
218
|
else if (isWhileStep(step)) await this.executeWhileStep(step, steps, scope, extraCtx);
|
|
180
219
|
else if (isSwitchStep(step)) await this.executeSwitchStep(step, steps, scope, extraCtx);
|
|
181
220
|
else if (isTryStep(step)) await this.executeTryStep(step, steps, scope, extraCtx);
|
|
221
|
+
else if (isThrowStep(step)) this.executeThrowStep(step, steps, extraCtx);
|
|
182
222
|
else throw new Error(`Step "${(step as Step).name}" has no recognized type key`);
|
|
183
223
|
}
|
|
184
224
|
|
|
@@ -200,7 +240,8 @@ class RunSequence {
|
|
|
200
240
|
} else {
|
|
201
241
|
const ref = raw as KindRef<Invocable>;
|
|
202
242
|
if (scope) {
|
|
203
|
-
|
|
243
|
+
const instance = scope.getInstance(ref.name) as unknown as ResourceInstance;
|
|
244
|
+
result = await this.ctx.invokeResolved(ref.kind, ref.name, instance, inputs);
|
|
204
245
|
} else {
|
|
205
246
|
result = await this.ctx.invoke(ref.kind, ref.name, inputs, { retry: step.retry });
|
|
206
247
|
}
|
|
@@ -217,7 +258,19 @@ class RunSequence {
|
|
|
217
258
|
): Promise<void> {
|
|
218
259
|
if (this.ctx.expandValue(step.if, { steps, ...extraCtx })) {
|
|
219
260
|
await this.executeSteps(step.then, steps, scope, extraCtx);
|
|
220
|
-
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (step.elseif) {
|
|
265
|
+
for (const branch of step.elseif) {
|
|
266
|
+
if (this.ctx.expandValue(branch.if, { steps, ...extraCtx })) {
|
|
267
|
+
await this.executeSteps(branch.then, steps, scope, extraCtx);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (step.else) {
|
|
221
274
|
await this.executeSteps(step.else, steps, scope, extraCtx);
|
|
222
275
|
}
|
|
223
276
|
}
|
|
@@ -249,6 +302,32 @@ class RunSequence {
|
|
|
249
302
|
}
|
|
250
303
|
}
|
|
251
304
|
|
|
305
|
+
private executeThrowStep(
|
|
306
|
+
step: ThrowStep,
|
|
307
|
+
steps: Record<string, unknown>,
|
|
308
|
+
extraCtx: Record<string, unknown>,
|
|
309
|
+
): never {
|
|
310
|
+
const cel = { steps, ...extraCtx };
|
|
311
|
+
const expanded = this.ctx.expandValue(step.throw, cel) as {
|
|
312
|
+
code: unknown;
|
|
313
|
+
message?: unknown;
|
|
314
|
+
data?: unknown;
|
|
315
|
+
};
|
|
316
|
+
const code = expanded?.code;
|
|
317
|
+
if (typeof code !== "string" || code.length === 0) {
|
|
318
|
+
// Structured error (not plain Error) so the failure stays in the InvokeError
|
|
319
|
+
// channel and a route's `catches:` list can still map it. The alternative —
|
|
320
|
+
// a plain Error — would skip catches: entirely and fall through to a 500.
|
|
321
|
+
throw new InvokeError(
|
|
322
|
+
"INVALID_THROW_STEP",
|
|
323
|
+
`Run.Sequence step "${step.name}": throw.code is required and must resolve to a non-empty string`,
|
|
324
|
+
{ step: step.name, code },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const message = typeof expanded.message === "string" ? expanded.message : code;
|
|
328
|
+
throw new InvokeError(code, message, expanded.data);
|
|
329
|
+
}
|
|
330
|
+
|
|
252
331
|
private async executeTryStep(
|
|
253
332
|
step: TryStep,
|
|
254
333
|
steps: Record<string, unknown>,
|
|
@@ -300,12 +379,11 @@ class RunSequence {
|
|
|
300
379
|
}
|
|
301
380
|
|
|
302
381
|
function toSequenceError(err: unknown, stepName: string): SequenceError {
|
|
382
|
+
if (isInvokeError(err)) {
|
|
383
|
+
return { message: err.message, code: err.code, data: err.data, step: stepName };
|
|
384
|
+
}
|
|
303
385
|
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
-
|
|
305
|
-
err instanceof Error && (err as Error & { code?: string }).code != null
|
|
306
|
-
? (err as Error & { code?: string }).code!
|
|
307
|
-
: null;
|
|
308
|
-
return { message, code, step: stepName };
|
|
386
|
+
return { message, code: null, data: undefined, step: stepName };
|
|
309
387
|
}
|
|
310
388
|
|
|
311
389
|
export function register(): void {}
|