@lambdot/core 0.1.0 → 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/src/kernel.ts CHANGED
@@ -1,306 +1,275 @@
1
- import type { ContextView } from "./context.ts";
2
- import type { OutputContractMap } from "./context.ts";
3
- import { collectEffect, type Disposer } from "./effect.ts";
4
- import { EventBus, INGRESS, type BotEvent, type OnOptions } from "./events.ts";
5
- import type { EventMap } from "./events.ts";
6
- import { Fiber } from "./fiber.ts";
7
- import type {
8
- AnyPlugin,
9
- CapsOf,
10
- ConfigOf,
11
- EventsOf,
12
- OutputsOf,
13
- Spread,
14
- StateOf,
15
- Validate,
16
- } from "./plugin.ts";
1
+ import { runDisposers, type Disposer } from "./effect.ts";
2
+ import type { Composite, Plugin, PluginSpec, Scope } from "./plugin.ts";
17
3
  import { validateConfig } from "./schema.ts";
18
- import { createStateAccessor, type StateBackend } from "./state.ts";
19
4
 
20
5
  export interface KernelOptions {
21
- /** Sink for errors thrown by fire-and-forget listeners and disposers. */
6
+ /** Sink for errors reported through `scope.onError` and background activations. */
22
7
  onError?: (error: unknown) => void;
23
8
  }
24
9
 
10
+ /** Runtime view of anything wireable: a leaf plugin or a nested composition. */
11
+ interface RuntimeUnit {
12
+ readonly name: string;
13
+ activate(input: unknown, scope: Scope, config: unknown): Promise<unknown>;
14
+ }
15
+
16
+ interface Entry {
17
+ readonly unit: RuntimeUnit;
18
+ /** The namespace key the unit's output is stored under (`as` ?? name). */
19
+ readonly key: string;
20
+ readonly visible: boolean;
21
+ readonly mapping: ((ctx: Record<string, unknown>) => unknown) | undefined;
22
+ readonly config: unknown;
23
+ }
24
+
25
+ const defaultOnError = (error: unknown): void => console.error("[lambdot]", error);
26
+
27
+ function makeScope(disposers: Disposer[], onError: (error: unknown) => void): Scope {
28
+ return {
29
+ onDispose: (disposer) => {
30
+ disposers.push(disposer);
31
+ },
32
+ onError,
33
+ };
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null;
38
+ }
39
+
25
40
  /**
26
- * The runtime context. Untyped internally; the kernel exposes it through
27
- * {@link ContextView} parameterized by the folded type arguments.
41
+ * A leaf plugin at runtime. The composition methods seed an artifact chain
42
+ * with the plugin itself as the first entry, so a lone plugin is runnable.
28
43
  */
29
- class RuntimeContext {
30
- readonly bus: EventBus;
31
- private readonly outputs = new Map<
32
- string,
33
- { send(to: never, content: never): void | Promise<void> }
34
- >();
35
- private readonly provided = new Map<string, unknown>();
36
- /** Events are processed sequentially, in ingestion order. */
37
- private queue: Promise<void> = Promise.resolve();
38
- onProvideChange: (name: string, available: boolean) => void = () => {};
39
-
40
- constructor(private readonly onError: (error: unknown) => void) {
41
- this.bus = new EventBus(onError);
42
- }
44
+ class PluginRuntime implements RuntimeUnit {
45
+ private artifact: CompositeRuntime | undefined;
43
46
 
44
- on(kind: string, listener: never, options?: OnOptions): Disposer {
45
- return this.bus.on(kind, listener, options);
47
+ constructor(private readonly spec: PluginSpec<any, any, any, string>) {
48
+ this.name = spec.name;
46
49
  }
47
50
 
48
- emit(kind: string, event: unknown): void {
49
- this.bus.emit(kind, event);
50
- }
51
+ readonly name: string;
51
52
 
52
- parallel(kind: string, event: unknown): Promise<void> {
53
- return this.bus.parallel(kind, event);
53
+ get Config(): PluginSpec<any, any, any, string>["Config"] {
54
+ return this.spec.Config;
54
55
  }
55
56
 
56
- serial(kind: string, event: unknown): Promise<unknown> {
57
- return this.bus.serial(kind, event);
57
+ apply(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
58
+ return this.activate(input, scope, config);
58
59
  }
59
60
 
60
- waterfall(
61
- kind: string,
62
- event: unknown,
63
- inner: (event: any) => Promise<unknown>,
64
- ): Promise<unknown> {
65
- return this.bus.waterfall(kind, event, inner);
61
+ async activate(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
62
+ const validated = this.spec.Config
63
+ ? await validateConfig(this.spec.name, this.spec.Config, config)
64
+ : config;
65
+ return await this.spec.apply(input, scope, validated);
66
66
  }
67
67
 
68
- async send(to: { platform: string }, content: unknown): Promise<void> {
69
- const output = this.outputs.get(to.platform);
70
- if (!output) throw new Error(`no output registered for platform "${to.platform}"`);
71
- await output.send(to as never, content as never);
68
+ use(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
69
+ return this.asArtifact().use(unit, options);
72
70
  }
73
71
 
74
- registerOutput(
75
- platform: string,
76
- output: { send(to: never, content: never): void | Promise<void> },
77
- ): Disposer {
78
- if (this.outputs.has(platform))
79
- throw new Error(`duplicate output for platform "${platform}"`);
80
- this.outputs.set(platform, output);
81
- return () => {
82
- this.outputs.delete(platform);
83
- };
72
+ bind(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
73
+ return this.asArtifact().bind(unit, options);
84
74
  }
85
75
 
86
- readonly state = {
87
- for: (plugin: string) => {
88
- // State has no dedicated runtime slot: a state plugin is an
89
- // ordinary feature plugin that provides its backend as the
90
- // (runtime-gated) "state" capability.
91
- const backend = this.provided.get("state") as StateBackend | undefined;
92
- if (!backend)
93
- throw new Error(
94
- 'no state backend active — register a state plugin and add `inject: ["state"]`',
95
- );
96
- return createStateAccessor(backend, plugin);
97
- },
98
- };
99
-
100
- provide(name: string, value?: unknown): Disposer {
101
- if (this.provided.has(name)) throw new Error(`capability "${name}" is already provided`);
102
- this.provided.set(name, value);
103
- const defined = value !== undefined && !(name in this);
104
- if (defined) {
105
- Object.defineProperty(this, name, { value, configurable: true });
106
- }
107
- this.onProvideChange(name, true);
108
- return () => {
109
- this.provided.delete(name);
110
- if (defined) {
111
- delete (this as Record<string, unknown>)[name];
112
- }
113
- this.onProvideChange(name, false);
114
- };
76
+ start(input?: unknown): Promise<void> {
77
+ return this.asArtifact().start(input);
115
78
  }
116
79
 
117
- isProvided(name: string): boolean {
118
- return this.provided.has(name);
80
+ async stop(): Promise<void> {
81
+ await this.artifact?.stop();
119
82
  }
120
83
 
121
- ingest(kind: string, payload: unknown, address: unknown): Promise<void> {
122
- const event: BotEvent = { kind, payload, address, id: crypto.randomUUID(), at: Date.now() };
123
- const run = this.queue.then(() => this.process(event));
124
- // A failing event rejects its own caller but never jams the queue.
125
- this.queue = run.catch(this.onError);
126
- return run;
84
+ get ctx(): Record<string, unknown> {
85
+ return this.asArtifact().ctx;
127
86
  }
128
87
 
129
- private async process(event: BotEvent): Promise<void> {
130
- await this.bus.waterfall(INGRESS, event, async (current: BotEvent) => {
131
- await this.bus.parallel(current.kind, current);
132
- });
88
+ private asArtifact(): CompositeRuntime {
89
+ if (!this.artifact) {
90
+ this.artifact = new CompositeRuntime(
91
+ [
92
+ {
93
+ unit: this,
94
+ key: this.spec.name,
95
+ visible: true,
96
+ mapping: undefined,
97
+ config: undefined,
98
+ },
99
+ ],
100
+ {},
101
+ this.spec.name,
102
+ );
103
+ }
104
+ return this.artifact;
133
105
  }
134
106
  }
135
107
 
136
- interface FiberEntry {
137
- readonly plugin: AnyPlugin;
138
- readonly config: unknown;
139
- readonly fiber: Fiber;
108
+ interface WireOptions {
109
+ readonly mapping?: (ctx: Record<string, unknown>) => unknown;
110
+ readonly option?: unknown;
111
+ readonly as?: string;
140
112
  }
141
113
 
142
114
  /**
143
- * The kernel. Stateless: it owns no conversational data, only the fiber
144
- * registry and the runtime wiring. Every `use()` folds the plugin's type
145
- * contribution into the kernel's type parameters the context type your
146
- * plugins see is computed from the plugins you registered.
115
+ * A composition at runtime. Inert while built: `start` activates the entries
116
+ * in composition order resolve the mapping (or identity-wire the visible
117
+ * context), activate the unit, stash its output under its namespace key —
118
+ * and `stop` disposes in reverse. Activation order is definition order;
119
+ * ordering mistakes are compile errors in the mappings, not runtime states.
147
120
  */
148
- export class Kernel<
149
- TEvents extends EventMap = {},
150
- TOutputs extends OutputContractMap = {},
151
- TCaps extends object = {},
152
- TState extends object = {},
153
- > {
154
- /** The typed context. This is the fold of everything registered so far. */
155
- readonly ctx: ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
156
-
157
- private readonly runtime: RuntimeContext;
158
- private readonly entries: FiberEntry[] = [];
159
- private readonly onError: (error: unknown) => void;
160
- private readonly inflightActivations = new Set<Promise<void>>();
121
+ class CompositeRuntime implements RuntimeUnit {
122
+ readonly ctx: Record<string, unknown> = {};
123
+ private readonly hidden: Record<string, unknown> = {};
124
+ private readonly activated: { entry: Entry; disposers: Disposer[] }[] = [];
125
+ private inputRecord: Record<string, unknown> = {};
161
126
  private started = false;
127
+ private readonly onError: (error: unknown) => void;
162
128
 
163
- constructor(options: KernelOptions = {}) {
164
- this.onError = options.onError ?? ((error) => console.error("[lambdot]", error));
165
- this.runtime = new RuntimeContext(this.onError);
166
- this.runtime.onProvideChange = (name, available) => {
167
- if (!available) void this.deactivateDependents(name).catch(this.onError);
168
- if (available) void this.activateEligible().catch(this.onError);
169
- };
170
- this.ctx = this.runtime as unknown as ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
129
+ constructor(
130
+ private readonly entries: Entry[],
131
+ options: KernelOptions,
132
+ readonly name: string,
133
+ ) {
134
+ this.onError = options.onError ?? defaultOnError;
135
+ }
136
+
137
+ /** Set by `expose`: composing onto a sealed chain is a runtime error. */
138
+ private exposed: string | undefined;
139
+
140
+ use(unit: RuntimeUnit, options?: WireOptions): this {
141
+ return this.add(unit, options, true);
142
+ }
143
+
144
+ bind(unit: RuntimeUnit, options?: WireOptions): this {
145
+ return this.add(unit, options, false);
146
+ }
147
+
148
+ /** Seal the chain and return it as a named engine — the final artifact. */
149
+ expose(name: string): EngineRuntime {
150
+ // Explicit undefined check: "" is a valid name and must still seal.
151
+ if (this.exposed !== undefined)
152
+ throw new Error(`kernel already exposed as engine "${this.exposed}"`);
153
+ this.exposed = name;
154
+ return new EngineRuntime(this, name);
171
155
  }
172
156
 
173
- /**
174
- * Register a plugin. Gated at compile time: every event kind a feature
175
- * handles, every output platform it sends through, and every typed
176
- * capability it injects must already be in the fold.
177
- */
178
- use<TPlugin extends AnyPlugin>(
179
- plugin: TPlugin & Validate<TPlugin, TEvents, TOutputs, TCaps>,
180
- ...config: Spread<ConfigOf<TPlugin>>
181
- ): Kernel<
182
- TEvents & EventsOf<TPlugin>,
183
- TOutputs & OutputsOf<TPlugin>,
184
- TCaps & CapsOf<TPlugin>,
185
- TState & StateOf<TPlugin>
186
- > {
187
- const entry: FiberEntry = {
188
- plugin,
189
- config: config[0],
190
- fiber: new Fiber(plugin, config[0]),
157
+ private add(unit: RuntimeUnit, options: WireOptions | undefined, visible: boolean): this {
158
+ if (this.exposed !== undefined)
159
+ throw new Error(`cannot compose onto a kernel exposed as engine "${this.exposed}"`);
160
+ const entry: Entry = {
161
+ unit,
162
+ key: options?.as ?? unit.name,
163
+ visible,
164
+ mapping: options?.mapping,
165
+ config: options?.option,
191
166
  };
192
167
  this.entries.push(entry);
193
- if (this.started) void this.activateEligible().catch(this.onError);
194
- return this as unknown as Kernel<
195
- TEvents & EventsOf<TPlugin>,
196
- TOutputs & OutputsOf<TPlugin>,
197
- TCaps & CapsOf<TPlugin>,
198
- TState & StateOf<TPlugin>
199
- >;
168
+ // Composing onto a running artifact activates in the background —
169
+ // same fire-and-report semantics as composition before `start`.
170
+ if (this.started) void this.activateEntry(entry).catch(this.onError);
171
+ return this;
172
+ }
173
+
174
+ /** RuntimeUnit: run as a nested unit, torn down with the parent. */
175
+ async activate(input: unknown, scope: Scope, _config: unknown): Promise<unknown> {
176
+ scope.onDispose(() => this.stop());
177
+ await this.start(input);
178
+ return this.ctx;
200
179
  }
201
180
 
202
- /** Activate all plugins whose `inject` requirements are satisfiable. */
203
- async start(): Promise<void> {
181
+ async start(input?: unknown): Promise<void> {
204
182
  if (this.started) return;
205
183
  this.started = true;
206
- await this.activateEligible();
207
- const stuck = this.entries
208
- .filter((entry) => entry.fiber.state === "pending")
209
- .map((entry) => entry.plugin.name);
210
- if (stuck.length > 0)
211
- console.warn(`[lambdot] plugins pending on missing capabilities: ${stuck.join(", ")}`);
184
+ this.inputRecord = isRecord(input) ? input : {};
185
+ try {
186
+ for (const entry of this.entries) {
187
+ if (this.activated.some((done) => done.entry === entry)) continue;
188
+ await this.activateEntry(entry);
189
+ }
190
+ } catch (error) {
191
+ await this.stop();
192
+ throw error;
193
+ }
212
194
  }
213
195
 
214
- /** Dispose every active fiber in reverse registration order. */
215
196
  async stop(): Promise<void> {
216
197
  this.started = false;
217
- for (const entry of [...this.entries].reverse()) {
218
- await entry.fiber.dispose("disposed", this.onError);
198
+ for (const { entry, disposers } of this.activated.splice(0).reverse()) {
199
+ await runDisposers(disposers, this.onError);
200
+ if (entry.visible) delete this.ctx[entry.key];
201
+ else delete this.hidden[entry.key];
219
202
  }
220
203
  }
221
204
 
222
- private eligible(plugin: AnyPlugin): boolean {
223
- return (plugin.inject ?? []).every((name) => this.runtime.isProvided(name));
205
+ private async activateEntry(entry: Entry): Promise<void> {
206
+ if (entry.key in this.ctx || entry.key in this.hidden)
207
+ throw new Error(`duplicate namespace "${entry.key}"`);
208
+ const disposers: Disposer[] = [];
209
+ // Identity wiring hands over the whole visible context; the unit's
210
+ // declared input type narrows the view at compile time.
211
+ const wireCtx = { ...this.inputRecord, ...this.hidden, ...this.ctx };
212
+ const input = entry.mapping ? entry.mapping(wireCtx) : wireCtx;
213
+ const output = await entry.unit.activate(
214
+ input,
215
+ makeScope(disposers, this.onError),
216
+ entry.config,
217
+ );
218
+ if (entry.visible) this.ctx[entry.key] = output;
219
+ else this.hidden[entry.key] = output;
220
+ this.activated.push({ entry, disposers });
224
221
  }
222
+ }
225
223
 
226
- private async activateEligible(): Promise<void> {
227
- if (!this.started) return;
228
- let progressed = true;
229
- while (progressed) {
230
- progressed = false;
231
- for (const entry of this.entries) {
232
- if (entry.fiber.state !== "pending" || !this.eligible(entry.plugin)) continue;
233
- await this.activate(entry);
234
- progressed = true;
235
- }
236
- }
237
- // Nested passes (triggered by `provide` during activation) may still
238
- // have activations in flight; wait for quiescence so `start()` and
239
- // `stop()` only observe settled fibers.
240
- while (this.inflightActivations.size > 0) {
241
- await Promise.allSettled(this.inflightActivations);
242
- }
243
- }
224
+ /**
225
+ * The runtime behind `Composite.expose`: a thin, sealed façade over the
226
+ * chain. Lifecycle delegates to the inner composition; as a nested unit it
227
+ * activates exactly like the chain itself — the only difference is the name
228
+ * and the erased type.
229
+ */
230
+ class EngineRuntime implements RuntimeUnit {
231
+ constructor(
232
+ private readonly inner: CompositeRuntime,
233
+ readonly name: string,
234
+ ) {}
244
235
 
245
- private activate(entry: FiberEntry): Promise<void> {
246
- // Mark the fiber synchronously so a nested `activateEligible` pass
247
- // cannot pick it up again while activation is in flight.
248
- entry.fiber.state = "activating";
249
- const activation = this.completeActivation(entry);
250
- this.inflightActivations.add(activation);
251
- const settled = () => this.inflightActivations.delete(activation);
252
- activation.then(settled, settled);
253
- return activation;
236
+ /** Public contract: apply the engine directly, like any other unit. */
237
+ apply(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
238
+ return this.activate(input, scope, config);
254
239
  }
255
240
 
256
- private async completeActivation(entry: FiberEntry): Promise<void> {
257
- const { plugin, fiber } = entry;
258
- let config = entry.config;
259
- try {
260
- if (plugin.Config) config = await validateConfig(plugin.name, plugin.Config, config);
261
- } catch (error) {
262
- fiber.state = "pending";
263
- throw error;
264
- }
265
- // The kernel may have stopped, or a required capability may have been
266
- // withdrawn, while we awaited; mid-activation fibers are invisible to
267
- // `deactivateDependents` and `stop`.
268
- if (!this.started || !this.eligible(plugin)) {
269
- fiber.state = "pending";
270
- return;
271
- }
272
-
273
- // Role-specific registration happens before `apply` so plugin code
274
- // can rely on its own capability being live.
275
- if (plugin.role === "output") {
276
- fiber.state = "active";
277
- fiber.addDisposer(this.runtime.registerOutput(plugin.platform, plugin));
278
- } else {
279
- fiber.state = "active";
280
- }
241
+ activate(input: unknown, scope: Scope, _config: unknown): Promise<unknown> {
242
+ return this.inner.activate(input, scope, undefined);
243
+ }
281
244
 
282
- if ("apply" in plugin && plugin.apply) {
283
- await collectEffect(plugin.apply(this.runtime as never, config as never), (disposer) =>
284
- fiber.addDisposer(disposer),
285
- );
286
- }
245
+ start(input?: unknown): Promise<void> {
246
+ return this.inner.start(input);
247
+ }
287
248
 
288
- const provides = plugin.provide ? [plugin.provide].flat() : [];
289
- for (const name of provides) {
290
- fiber.addDisposer(this.runtime.provide(name));
291
- }
249
+ stop(): Promise<void> {
250
+ return this.inner.stop();
292
251
  }
293
252
 
294
- private async deactivateDependents(capability: string): Promise<void> {
295
- for (const entry of [...this.entries].reverse()) {
296
- if (entry.fiber.state !== "active") continue;
297
- if (!(entry.plugin.inject ?? []).includes(capability)) continue;
298
- await entry.fiber.dispose("pending", this.onError);
299
- }
253
+ get ctx(): Record<string, unknown> {
254
+ return this.inner.ctx;
300
255
  }
301
256
  }
302
257
 
303
- /** Create an empty kernel. Fold plugins in with `.use(...)`. */
304
- export function createKernel(options?: KernelOptions): Kernel {
305
- return new Kernel(options);
258
+ /**
259
+ * Author a plugin. The spec is just `name`, optional `Config` schema, and
260
+ * `apply`; the returned plugin carries the composition methods (`use`,
261
+ * `bind`, `start`, `stop`, `ctx`).
262
+ */
263
+ export function definePlugin<
264
+ TIn = void,
265
+ TOut = void,
266
+ TConfig = void,
267
+ TName extends string = string,
268
+ >(spec: PluginSpec<TIn, TOut, TConfig, TName>): Plugin<TIn, TOut, TConfig, TName> {
269
+ return new PluginRuntime(spec) as never;
270
+ }
271
+
272
+ /** Create an empty composition. Wire plugins in with `.use(...)` / `.bind(...)`. */
273
+ export function createKernel(options?: KernelOptions): Composite<void, {}, {}, "kernel"> {
274
+ return new CompositeRuntime([], options ?? {}, "kernel") as never;
306
275
  }
package/src/message.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The envelope flowing through streams: a payload plus its return address.
3
+ * The core never inspects the address beyond the `platform` routing tag.
4
+ */
5
+ export interface Address<TPlatform extends string = string> {
6
+ readonly platform: TPlatform;
7
+ }
8
+
9
+ /**
10
+ * One inbound event. `payload` and `address` are platform-defined; `id` and
11
+ * `at` are minted by the producing input (see {@link message}).
12
+ */
13
+ export interface Message<TPayload = unknown, TAddress = unknown> {
14
+ readonly payload: TPayload;
15
+ readonly address: TAddress;
16
+ /** Unique id for dedup and tracing. */
17
+ readonly id: string;
18
+ /** Unix epoch milliseconds. */
19
+ readonly at: number;
20
+ }
21
+
22
+ /** Mint a message envelope with a fresh id and timestamp. */
23
+ export function message<TPayload, TAddress>(
24
+ payload: TPayload,
25
+ address: TAddress,
26
+ ): Message<TPayload, TAddress> {
27
+ return { payload, address, id: crypto.randomUUID(), at: Date.now() };
28
+ }
29
+
30
+ /**
31
+ * One outbound reply: content addressed back through the platform that owns
32
+ * the address. Features emit streams of these; output plugins consume them.
33
+ */
34
+ export interface Command<TAddress = unknown, TContent = unknown> {
35
+ readonly address: TAddress;
36
+ readonly content: TContent;
37
+ }