@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 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.
@@ -1,4 +1,4 @@
1
- import type { Invocable, KindRef, ResourceContext, ScopeHandle } from "@telorun/sdk";
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
- type Step = InvokeStep | IfStep | WhileStep | SwitchStep | TryStep;
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.values(step.cases))
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
- result = await scope.getInstance(ref.name).invoke(inputs);
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
- else if (step.else) {
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
- const code = err instanceof Error && err.code != null
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.1.2",
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.2.7"
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 type { Invocable, KindRef, ResourceContext, ScopeContext, ScopeHandle } from "@telorun/sdk";
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
- type Step = InvokeStep | IfStep | WhileStep | SwitchStep | TryStep;
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.else) this.resolveInvokes(step.else);
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.values(step.cases)) this.resolveInvokes(branch);
105
- if (step.default) this.resolveInvokes(step.default);
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
- result = await (scope.getInstance(ref.name) as unknown as Invocable).invoke(inputs);
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
- } else if (step.else) {
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
- const code =
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 {}