@lambdot/core 0.1.0 → 0.1.1

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,227 @@
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
 
25
- /**
26
- * The runtime context. Untyped internally; the kernel exposes it through
27
- * {@link ContextView} parameterized by the folded type arguments.
28
- */
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 = () => {};
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
+ }
39
15
 
40
- constructor(private readonly onError: (error: unknown) => void) {
41
- this.bus = new EventBus(onError);
42
- }
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
+ }
43
24
 
44
- on(kind: string, listener: never, options?: OnOptions): Disposer {
45
- return this.bus.on(kind, listener, options);
46
- }
25
+ const defaultOnError = (error: unknown): void => console.error("[lambdot]", error);
47
26
 
48
- emit(kind: string, event: unknown): void {
49
- this.bus.emit(kind, event);
50
- }
27
+ function makeScope(disposers: Disposer[], onError: (error: unknown) => void): Scope {
28
+ return {
29
+ onDispose: (disposer) => {
30
+ disposers.push(disposer);
31
+ },
32
+ onError,
33
+ };
34
+ }
51
35
 
52
- parallel(kind: string, event: unknown): Promise<void> {
53
- return this.bus.parallel(kind, event);
54
- }
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null;
38
+ }
55
39
 
56
- serial(kind: string, event: unknown): Promise<unknown> {
57
- return this.bus.serial(kind, event);
58
- }
40
+ /**
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.
43
+ */
44
+ class PluginRuntime implements RuntimeUnit {
45
+ private artifact: CompositeRuntime | undefined;
59
46
 
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);
47
+ constructor(private readonly spec: PluginSpec<any, any, any, string>) {
48
+ this.name = spec.name;
66
49
  }
67
50
 
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);
51
+ readonly name: string;
52
+
53
+ get Config(): PluginSpec<any, any, any, string>["Config"] {
54
+ return this.spec.Config;
72
55
  }
73
56
 
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
- };
57
+ apply(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
58
+ return this.activate(input, scope, config);
84
59
  }
85
60
 
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
- };
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
+ }
99
67
 
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
- };
68
+ use(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
69
+ return this.asArtifact().use(unit, options);
115
70
  }
116
71
 
117
- isProvided(name: string): boolean {
118
- return this.provided.has(name);
72
+ bind(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
73
+ return this.asArtifact().bind(unit, options);
119
74
  }
120
75
 
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;
76
+ start(input?: unknown): Promise<void> {
77
+ return this.asArtifact().start(input);
127
78
  }
128
79
 
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
- });
80
+ async stop(): Promise<void> {
81
+ await this.artifact?.stop();
82
+ }
83
+
84
+ get ctx(): Record<string, unknown> {
85
+ return this.asArtifact().ctx;
86
+ }
87
+
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;
171
135
  }
172
136
 
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]),
191
- };
192
- 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
- >;
137
+ use(unit: RuntimeUnit, options?: WireOptions): this {
138
+ return this.add(unit, options, true);
200
139
  }
201
140
 
202
- /** Activate all plugins whose `inject` requirements are satisfiable. */
203
- async start(): Promise<void> {
204
- if (this.started) return;
205
- 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(", ")}`);
141
+ bind(unit: RuntimeUnit, options?: WireOptions): this {
142
+ return this.add(unit, options, false);
212
143
  }
213
144
 
214
- /** Dispose every active fiber in reverse registration order. */
215
- async stop(): Promise<void> {
216
- this.started = false;
217
- for (const entry of [...this.entries].reverse()) {
218
- await entry.fiber.dispose("disposed", this.onError);
219
- }
145
+ private add(unit: RuntimeUnit, options: WireOptions | undefined, visible: boolean): this {
146
+ const entry: Entry = {
147
+ unit,
148
+ key: options?.as ?? unit.name,
149
+ visible,
150
+ mapping: options?.mapping,
151
+ config: options?.option,
152
+ };
153
+ this.entries.push(entry);
154
+ // Composing onto a running artifact activates in the background —
155
+ // same fire-and-report semantics as composition before `start`.
156
+ if (this.started) void this.activateEntry(entry).catch(this.onError);
157
+ return this;
220
158
  }
221
159
 
222
- private eligible(plugin: AnyPlugin): boolean {
223
- return (plugin.inject ?? []).every((name) => this.runtime.isProvided(name));
160
+ /** RuntimeUnit: run as a nested unit, torn down with the parent. */
161
+ async activate(input: unknown, scope: Scope, _config: unknown): Promise<unknown> {
162
+ scope.onDispose(() => this.stop());
163
+ await this.start(input);
164
+ return this.ctx;
224
165
  }
225
166
 
226
- private async activateEligible(): Promise<void> {
227
- if (!this.started) return;
228
- let progressed = true;
229
- while (progressed) {
230
- progressed = false;
167
+ async start(input?: unknown): Promise<void> {
168
+ if (this.started) return;
169
+ this.started = true;
170
+ this.inputRecord = isRecord(input) ? input : {};
171
+ try {
231
172
  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;
173
+ if (this.activated.some((done) => done.entry === entry)) continue;
174
+ await this.activateEntry(entry);
235
175
  }
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
- }
244
-
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;
254
- }
255
-
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
176
  } catch (error) {
262
- fiber.state = "pending";
177
+ await this.stop();
263
178
  throw error;
264
179
  }
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
- }
281
-
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
- }
180
+ }
287
181
 
288
- const provides = plugin.provide ? [plugin.provide].flat() : [];
289
- for (const name of provides) {
290
- fiber.addDisposer(this.runtime.provide(name));
182
+ async stop(): Promise<void> {
183
+ this.started = false;
184
+ for (const { entry, disposers } of this.activated.splice(0).reverse()) {
185
+ await runDisposers(disposers, this.onError);
186
+ if (entry.visible) delete this.ctx[entry.key];
187
+ else delete this.hidden[entry.key];
291
188
  }
292
189
  }
293
190
 
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
- }
191
+ private async activateEntry(entry: Entry): Promise<void> {
192
+ if (entry.key in this.ctx || entry.key in this.hidden)
193
+ throw new Error(`duplicate namespace "${entry.key}"`);
194
+ const disposers: Disposer[] = [];
195
+ // Identity wiring hands over the whole visible context; the unit's
196
+ // declared input type narrows the view at compile time.
197
+ const wireCtx = { ...this.inputRecord, ...this.hidden, ...this.ctx };
198
+ const input = entry.mapping ? entry.mapping(wireCtx) : wireCtx;
199
+ const output = await entry.unit.activate(
200
+ input,
201
+ makeScope(disposers, this.onError),
202
+ entry.config,
203
+ );
204
+ if (entry.visible) this.ctx[entry.key] = output;
205
+ else this.hidden[entry.key] = output;
206
+ this.activated.push({ entry, disposers });
300
207
  }
301
208
  }
302
209
 
303
- /** Create an empty kernel. Fold plugins in with `.use(...)`. */
304
- export function createKernel(options?: KernelOptions): Kernel {
305
- return new Kernel(options);
210
+ /**
211
+ * Author a plugin. The spec is just `name`, optional `Config` schema, and
212
+ * `apply`; the returned plugin carries the composition methods (`use`,
213
+ * `bind`, `start`, `stop`, `ctx`).
214
+ */
215
+ export function definePlugin<
216
+ TIn = void,
217
+ TOut = void,
218
+ TConfig = void,
219
+ TName extends string = string,
220
+ >(spec: PluginSpec<TIn, TOut, TConfig, TName>): Plugin<TIn, TOut, TConfig, TName> {
221
+ return new PluginRuntime(spec) as never;
222
+ }
223
+
224
+ /** Create an empty composition. Wire plugins in with `.use(...)` / `.bind(...)`. */
225
+ export function createKernel(options?: KernelOptions): Composite<void, {}, {}, "kernel"> {
226
+ return new CompositeRuntime([], options ?? {}, "kernel") as never;
306
227
  }
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
+ }