@godspeedai/cognate-runtime-bun 0.0.0-stage → 0.1.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.
@@ -0,0 +1,423 @@
1
+ // Durable run execution by deterministic replay (ADR-0011, spec §11, §12).
2
+ // Each execution re-reads the run stream, re-enters the agent from the top, and answers every
3
+ // RunContext call that already has a recorded result from the stream instead of repeating it.
4
+ import { ConcurrencyError, fingerprint, IdempotencyMismatchError, type Json, type NewEvent, type StoredEvent } from "@godspeedai/cognate-events";
5
+ import type { Principal, SemanticRef } from "@godspeedai/cognate-kernel-api";
6
+ import { applyStateUpdate, type ArtifactInput, ContinuationClosedError, FrontendCapabilityError, foldState, InvocationFailedError, type RemoteCapabilityOffer, type RunContext, StatePatchError, type WaitSpec } from "@godspeedai/cognate-runtime-api";
7
+ import type { Core, RecallResultJson } from "./core.ts";
8
+ import { field, foldRun, isTerminal, messageOf, meta, RESUME_HANDLER, runStateStream, runStream, threadStateStream, toJson } from "./fold.ts";
9
+
10
+ /** Internal control flow: the execution stops here (suspended, cancelled, or lost a write race). */
11
+ class Halt {
12
+ constructor(readonly retry: boolean) {}
13
+ }
14
+
15
+ export type ExecutionResult = "done" | "retry";
16
+
17
+ export async function executeOnce(core: Core, runId: string): Promise<ExecutionResult> {
18
+ const streamId = runStream(runId);
19
+ const events = await core.store.readStream(streamId);
20
+ const view = foldRun(runId, events);
21
+ if (!view || isTerminal(view)) return "done";
22
+ const agent = core.agents.get(view.agent);
23
+ const started = events[0]!;
24
+
25
+ let version = events.length;
26
+ let lastEventId = events.at(-1)!.eventId;
27
+ const recorded = new Map<string, StoredEvent[]>();
28
+ for (const event of events) {
29
+ const stepId = field<string>(meta(event), "stepId");
30
+ if (stepId) recorded.set(stepId, [...(recorded.get(stepId) ?? []), event]);
31
+ }
32
+ const find = (stepId: string, eventType?: string) => recorded.get(stepId)?.find((e) => eventType === undefined || e.eventType === eventType);
33
+
34
+ const controller = new AbortController();
35
+ core.controllers.set(runId, controller);
36
+ let halted: Halt | undefined;
37
+ const halt = (retry: boolean): never => {
38
+ halted ??= new Halt(retry);
39
+ throw halted;
40
+ };
41
+ const guard = () => {
42
+ if (halted) throw halted;
43
+ if (controller.signal.aborted) halt(false);
44
+ };
45
+
46
+ // Appends are serialized so concurrent RunContext calls cannot race on the stream version.
47
+ let writes: Promise<unknown> = Promise.resolve();
48
+ const append = (event: { eventType: string; payload: Json; metadata: { [key: string]: Json }; semanticRef?: SemanticRef | null; componentId?: string | null; fiberId?: string | null }): Promise<StoredEvent> => {
49
+ const run = writes.then(async () => {
50
+ guard();
51
+ const envelope: NewEvent = {
52
+ eventType: event.eventType,
53
+ schemaVersion: 1,
54
+ occurredAt: new Date().toISOString(),
55
+ tenant: view.tenant,
56
+ correlationId: view.correlationId,
57
+ causationId: lastEventId,
58
+ runId,
59
+ scopeId: started.scopeId,
60
+ payload: event.payload,
61
+ metadata: event.metadata,
62
+ ...(event.semanticRef ? { semanticRef: event.semanticRef } : {}),
63
+ ...(event.componentId ? { componentId: event.componentId } : {}),
64
+ ...(event.fiberId ? { fiberId: event.fiberId } : {}),
65
+ };
66
+ try {
67
+ // Pre-assign the id so hooks can derive intents that commit atomically with the event (spec §13.1).
68
+ const pending = { ...envelope, eventId: crypto.randomUUID(), streamId, recordedAt: envelope.occurredAt };
69
+ const outcome = await core.store.commit({ appends: [{ streamId, expectedVersion: version, events: [{ ...envelope, eventId: pending.eventId }] }], outbox: core.outboxFor(pending) });
70
+ const stored = outcome.events[0]!;
71
+ version += 1;
72
+ lastEventId = stored.eventId;
73
+ return stored;
74
+ } catch (error) {
75
+ if (error instanceof ConcurrencyError) halt(true);
76
+ throw error;
77
+ }
78
+ });
79
+ writes = run.catch(() => {});
80
+ return run;
81
+ };
82
+
83
+ const counters = { emit: 0, invoke: 0, artifact: 0 };
84
+ const context: RunContext = {
85
+ runId,
86
+ agent: view.agent,
87
+ tenant: view.tenant,
88
+ scopeId: started.scopeId,
89
+ correlationId: view.correlationId,
90
+ actor: { id: view.startedBy, kind: (field<string>(meta(started), "startedByKind") ?? "user") as Principal["kind"] },
91
+ signal: controller.signal,
92
+
93
+ // D1: fn may itself call emit/invoke/artifact. Those calls draw ids from the shared `counters`
94
+ // object; when a step is replayed, fn is never re-run, so the drift it originally caused must be
95
+ // replayed onto `counters` too, or every id born after this step collides with an id already
96
+ // recorded (and is silently dropped — see emit()). We record the net drift in the completion
97
+ // event and, on replay, advance `counters` by it before returning the cached result.
98
+ // Assumption: agent code drives one step/emit/invoke/artifact call at a time (spec's sequential
99
+ // execution model) — concurrent sibling steps racing on the same shared counters can misattribute
100
+ // drift between them and are not supported.
101
+ async step<T extends Json>(id: string, fn: () => T | Promise<T>): Promise<T> {
102
+ guard();
103
+ const stepId = `step:${id}`;
104
+ const done = find(stepId);
105
+ if (done) {
106
+ const drift = field<{ readonly emit?: number; readonly invoke?: number; readonly artifact?: number }>(meta(done), "counters");
107
+ if (drift) {
108
+ counters.emit += drift.emit ?? 0;
109
+ counters.invoke += drift.invoke ?? 0;
110
+ counters.artifact += drift.artifact ?? 0;
111
+ }
112
+ return field(done.payload, "result") as T;
113
+ }
114
+ const before = { ...counters };
115
+ const result = toJson(await fn());
116
+ const drift = { emit: counters.emit - before.emit, invoke: counters.invoke - before.invoke, artifact: counters.artifact - before.artifact };
117
+ await append({ eventType: "run.step.completed", payload: { result }, metadata: { stepId, counters: drift } });
118
+ return result as T;
119
+ },
120
+
121
+ async emit(eventType: string, payload: Json): Promise<void> {
122
+ guard();
123
+ const stepId = `emit:${++counters.emit}`;
124
+ if (find(stepId)) return;
125
+ if (eventType.startsWith("run.")) throw new Error(`emit: event type ${eventType} is reserved for the runtime`);
126
+ if (eventType.startsWith("observation.")) throw new Error(`emit: event type ${eventType} is reserved for observation ingestion (RuntimeService.observe) — observations are never run emissions`);
127
+ await append({ eventType, payload: toJson(payload), metadata: { stepId } });
128
+ },
129
+
130
+ async invoke(capability: string, input: Json, options?: { readonly version?: string }): Promise<Json> {
131
+ guard();
132
+ const stepId = `invoke:${++counters.invoke}`;
133
+ // G1: progress events share this stepId (so a replayed invoke advances past them like any other
134
+ // recorded event) but are a distinct event type, so the completion check must name it explicitly.
135
+ let done = find(stepId, "run.invocation");
136
+ if (!done) {
137
+ // A crash between a progress call and the final run.invocation event leaves partial progress
138
+ // recorded for this stepId with no completion; re-executing calls the handler again under a
139
+ // fresh attempt number so consumers can discard the earlier partial attempt.
140
+ const priorAttempts = (recorded.get(stepId) ?? [])
141
+ .filter((e) => e.eventType === "run.invocation.progress")
142
+ .map((e) => field<number>(meta(e), "attempt") ?? 0);
143
+ const attempt = priorAttempts.length ? Math.max(...priorAttempts) + 1 : 0;
144
+ let seq = 0;
145
+ const progress = async (payload: Json): Promise<void> => {
146
+ guard();
147
+ await append({ eventType: "run.invocation.progress", payload: toJson(payload), metadata: { stepId, capability, attempt, seq: seq++ } });
148
+ };
149
+ const call = (approval?: { readonly approvalId: string }) =>
150
+ core.invoker.invoke({
151
+ capability,
152
+ ...(options?.version ? { version: options.version } : {}),
153
+ input: toJson(input),
154
+ tenant: view.tenant,
155
+ ...(approval ? { approval } : {}),
156
+ context: { actor: context.actor, correlationId: view.correlationId, causationId: lastEventId, runId, signal: controller.signal, progress },
157
+ });
158
+ // Governance escalation (migration Stage 7). The decision to wait is recorded once, so a replay of
159
+ // this step reuses it rather than asking the authority again (which would open a second approval).
160
+ // The wait is a durable continuation; whoever resumes it, the resume only triggers a *revalidation*
161
+ // with the authority, which alone decides whether the call may proceed.
162
+ const gateStep = `${stepId}:governance`;
163
+ const recordedGate = find(gateStep, "run.governance.escalated");
164
+ let approvalId = recordedGate ? field<string>(meta(recordedGate), "approvalId") : undefined;
165
+ let expiresAt = recordedGate ? field<number>(meta(recordedGate), "expiresAt") : undefined;
166
+ let outcome = approvalId ? undefined : await call();
167
+ guard();
168
+ if (outcome?.escalation) {
169
+ approvalId = outcome.escalation.approvalId;
170
+ expiresAt = outcome.escalation.expiresAt;
171
+ await append({ eventType: "run.governance.escalated", payload: { capability, approvalId }, metadata: { stepId: gateStep, capability, approvalId, expiresAt: expiresAt ?? null } });
172
+ }
173
+ if (approvalId) {
174
+ await context.waitFor(`governance:${stepId}`, {
175
+ type: "governance-approval",
176
+ condition: { kind: "governance_approval", approvalId },
177
+ payload: { capability, approvalId },
178
+ ...(typeof expiresAt === "number" ? { expiresAt } : {}),
179
+ });
180
+ outcome = await call({ approvalId });
181
+ guard();
182
+ }
183
+ if (!outcome) throw new Error("invoke: no outcome");
184
+ done = await append({
185
+ eventType: "run.invocation",
186
+ // Input and result live in the erasable payload (spec §32); routing facts stay in metadata.
187
+ payload: outcome.ok ? { input: toJson(input), result: outcome.value } : { input: toJson(input), failure: outcome.failure },
188
+ metadata: {
189
+ stepId,
190
+ capability,
191
+ providerId: outcome.providerId,
192
+ fiberId: outcome.fiberId,
193
+ decision: outcome.decision as unknown as Json,
194
+ ok: outcome.ok,
195
+ failureKind: outcome.failure?.kind ?? null,
196
+ },
197
+ semanticRef: outcome.semanticRef,
198
+ componentId: outcome.componentId,
199
+ fiberId: outcome.fiberId,
200
+ });
201
+ }
202
+ const m = meta(done);
203
+ if (m.ok === true) return field(done.payload, "result");
204
+ throw new InvocationFailedError(capability, field(done.payload, "failure") ?? { kind: field<string>(m, "failureKind") ?? "failed" });
205
+ },
206
+
207
+ async artifact(input: ArtifactInput): Promise<string> {
208
+ guard();
209
+ const n = ++counters.artifact;
210
+ const stepId = `artifact:${n}`;
211
+ const done = find(stepId);
212
+ if (done) return field<string>(meta(done), "artifactId")!;
213
+ const artifactId = `${runId}.artifact.${n}`;
214
+ // Idempotent across a crash between the artifact commit and the run event.
215
+ if (!(await core.store.getRecord("artifacts", artifactId))) {
216
+ await core.artifacts.put({
217
+ id: artifactId,
218
+ name: input.name,
219
+ mediaType: input.mediaType,
220
+ bytes: input.bytes,
221
+ access: { tenant: view.tenant, scopeId: started.scopeId, readers: [...(input.readers ?? [])] },
222
+ runId,
223
+ correlationId: view.correlationId,
224
+ causationId: lastEventId,
225
+ ...(input.representations ? { representations: input.representations } : {}),
226
+ });
227
+ }
228
+ await append({ eventType: "run.artifact", payload: { artifactId }, metadata: { stepId, artifactId } });
229
+ return artifactId;
230
+ },
231
+
232
+ async waitFor(id: string, spec: WaitSpec): Promise<Json> {
233
+ guard();
234
+ const stepId = `wait:${id}`;
235
+ const resumed = find(stepId, "run.resumed");
236
+ if (resumed) return field(resumed.payload, "input");
237
+ const closed = find(stepId, "run.wait.closed");
238
+ if (closed) throw new ContinuationClosedError(`continuation ${id} was ${String(field(meta(closed), "status")).toLowerCase()}`);
239
+
240
+ const continuationId = `${runId}.wait.${id}`;
241
+ if (!find(stepId, "run.waiting")) {
242
+ if (!(await core.continuations.get(continuationId))) {
243
+ await core.continuations.open({
244
+ id: continuationId,
245
+ type: spec.type,
246
+ tenant: view.tenant,
247
+ runId,
248
+ scopeId: started.scopeId,
249
+ correlationId: view.correlationId,
250
+ causationId: lastEventId,
251
+ condition: spec.condition,
252
+ payload: spec.payload,
253
+ handler: RESUME_HANDLER,
254
+ ...(spec.semanticRef ? { semanticRef: spec.semanticRef } : {}),
255
+ ...(spec.expiresAt !== undefined ? { expiresAt: spec.expiresAt } : {}),
256
+ });
257
+ }
258
+ await append({ eventType: "run.waiting", payload: { type: spec.type }, metadata: { stepId, continuationId }, semanticRef: spec.semanticRef ?? null });
259
+ }
260
+
261
+ const record = await core.continuations.get(continuationId);
262
+ guard();
263
+ switch (record?.status) {
264
+ case "RESUMED":
265
+ await append({ eventType: "run.resumed", payload: { input: record.resolution?.input ?? null }, metadata: { stepId, continuationId }, semanticRef: record.semanticRef });
266
+ return record.resolution?.input ?? null;
267
+ case "CANCELLED":
268
+ case "EXPIRED":
269
+ case "FAILED":
270
+ await append({ eventType: "run.wait.closed", payload: { reason: record.resolution?.reason ?? null }, metadata: { stepId, continuationId, status: record.status }, semanticRef: record.semanticRef });
271
+ throw new ContinuationClosedError(`continuation ${id} was ${record.status.toLowerCase()}`);
272
+ default:
273
+ return halt(false);
274
+ }
275
+ },
276
+
277
+ async recall(id: string, query: { readonly text: string; readonly k: number }): Promise<RecallResultJson> {
278
+ guard();
279
+ const stepId = `recall:${id}`;
280
+ const done = find(stepId);
281
+ if (done) return (field(done.payload, "result") as unknown as RecallResultJson | null) ?? { status: "degraded", items: [] };
282
+ const result = await core.recall({ text: query.text, k: query.k, actor: context.actor, tenant: view.tenant });
283
+ await append({ eventType: "run.recall", payload: { result: result as unknown as Json }, metadata: { stepId } });
284
+ return result;
285
+ },
286
+
287
+ async state(id: string): Promise<Json> {
288
+ guard();
289
+ const stepId = `state:${id}`;
290
+ const done = find(stepId);
291
+ if (done) return field(done.payload, "result") as Json;
292
+ // Fold this run's own stream as committed so far this execution plus the separate caller-write stream
293
+ // (spec §29.1); re-read both fresh so a caller write landed after this execution started is still seen.
294
+ const [runEvents, stateEvents] = await Promise.all([core.store.readStream(streamId), core.store.readStream(runStateStream(runId))]);
295
+ const result = foldState([...runEvents, ...stateEvents]);
296
+ await append({ eventType: "run.step.completed", payload: { result }, metadata: { stepId } });
297
+ return result;
298
+ },
299
+
300
+ async sharedState(id: string): Promise<Json> {
301
+ guard();
302
+ const caller = { tenant: view.tenant, actor: context.actor };
303
+ const decision = await core.governor.govern({ kind: "action", caller, action: "thread.state.read", resource: view.correlationId });
304
+ if (!decision.allow) throw new Error(`thread state read denied: ${decision.reason}`);
305
+ return context.step(`shared-state:${id}`, async () => foldState(await core.store.readStream(threadStateStream(view.tenant, view.correlationId))));
306
+ },
307
+
308
+ async updateSharedState(id: string, update): Promise<{ readonly state: Json; readonly version: number }> {
309
+ guard();
310
+ const caller = { tenant: view.tenant, actor: context.actor };
311
+ const decision = await core.governor.govern({ kind: "action", caller, action: "thread.state.update", resource: view.correlationId });
312
+ if (!decision.allow) throw new Error(`thread state update denied: ${decision.reason}`);
313
+ return context.step(`shared-state.update:${id}`, async () => {
314
+ const streamId = threadStateStream(view.tenant, view.correlationId);
315
+ for (let attempt = 0; attempt < 5; attempt++) {
316
+ const events = await core.store.readStream(streamId);
317
+ let state: Json;
318
+ try {
319
+ state = applyStateUpdate(foldState(events), update);
320
+ } catch (error) {
321
+ if (error instanceof StatePatchError) throw new Error(`invalid thread state update: ${error.message}`);
322
+ throw error;
323
+ }
324
+ try {
325
+ const outcome = await core.store.commit({
326
+ idempotency: {
327
+ scope: `${view.tenant}:${runId}:thread.state.update`,
328
+ key: id,
329
+ fingerprint: fingerprint({ threadId: view.correlationId, update: toJson(update) }),
330
+ },
331
+ appends: [{
332
+ streamId,
333
+ expectedVersion: events.length,
334
+ events: [{
335
+ eventType: "state.changed",
336
+ schemaVersion: 1,
337
+ occurredAt: new Date().toISOString(),
338
+ tenant: view.tenant,
339
+ correlationId: view.correlationId,
340
+ causationId: events.at(-1)?.eventId ?? lastEventId,
341
+ runId,
342
+ scopeId: started.scopeId,
343
+ payload: toJson(update),
344
+ metadata: { scope: "thread", threadId: view.correlationId, source: "agent", actor: context.actor.id, stepId: `shared-state.update:${id}` },
345
+ }],
346
+ }],
347
+ result: { state, version: events.length + 1 },
348
+ });
349
+ return outcome.result as { readonly state: Json; readonly version: number };
350
+ } catch (error) {
351
+ if (error instanceof ConcurrencyError) continue;
352
+ if (error instanceof IdempotencyMismatchError) throw new Error(`thread state update ${id} was replayed with different input`);
353
+ throw error;
354
+ }
355
+ }
356
+ throw new Error(`too much contention updating thread state for ${view.correlationId}`);
357
+ });
358
+ },
359
+
360
+ async remoteOffers(id: string): Promise<readonly RemoteCapabilityOffer[]> {
361
+ guard();
362
+ const stepId = `remote-offers:${id}`;
363
+ const done = find(stepId);
364
+ if (done) return (field(done.payload, "result") as unknown as readonly RemoteCapabilityOffer[] | null) ?? [];
365
+ const caller = { tenant: view.tenant, actor: context.actor };
366
+ const listed = core.remoteOffers.list(caller, view.agent);
367
+ const allowed = await Promise.all(listed.map(async (offer) => (await core.governor.govern({ kind: "action", action: "remote.offer.invoke", caller, resource: offer.id })).allow));
368
+ const result = listed.filter((_, index) => allowed[index]);
369
+ await append({ eventType: "run.remote_offers", payload: { result: toJson(result as unknown as Json) }, metadata: { stepId } });
370
+ return result;
371
+ },
372
+
373
+ async invokeRemoteOffer(id: string, offerId: string, input: Json, options: { readonly expiresAt?: number } = {}): Promise<Json> {
374
+ guard();
375
+ const waitStep = `wait:remote:${id}`;
376
+ // Availability is checked only before the durable selection. A replay resumes the same
377
+ // continuation even after a browser has disconnected or its offer has expired.
378
+ let offer: RemoteCapabilityOffer | undefined;
379
+ if (!find(waitStep, "run.waiting")) {
380
+ offer = core.remoteOffers.take({ tenant: view.tenant, actor: context.actor }, view.agent, offerId);
381
+ if (!offer) throw new FrontendCapabilityError(`remote offer ${offerId} is unavailable`);
382
+ const decision = await core.governor.govern({ kind: "action", caller: { tenant: view.tenant, actor: context.actor }, action: "remote.offer.invoke", resource: offer.id });
383
+ if (!decision.allow) throw new FrontendCapabilityError(`remote offer ${offerId} denied: ${decision.reason}`);
384
+ }
385
+ const selected = offer;
386
+ // On a replay this condition is ignored by waitFor because its recorded wait controls the
387
+ // durable result; use a neutral shape solely to retain the original method contract.
388
+ const result = (await context.waitFor(`remote:${id}`, {
389
+ type: `remote:${selected?.contract.id ?? "selected"}`,
390
+ condition: selected
391
+ ? { kind: "frontend_capability", capability: selected.contract.id, offerId: selected.id, leaseId: selected.leaseId, contractVersion: selected.contract.version }
392
+ : { kind: "frontend_capability" },
393
+ payload: toJson(input),
394
+ ...(options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt }),
395
+ })) as { ok?: boolean; value?: Json; error?: string } | null;
396
+ if (!result || result.ok !== true) throw new FrontendCapabilityError(`remote offer ${offerId} failed: ${result?.error ?? "no result"}`);
397
+ return result.value ?? null;
398
+ },
399
+ };
400
+
401
+ try {
402
+ let outcome: { ok: true; value: Json } | { ok: false; error: unknown };
403
+ if (!agent) outcome = { ok: false, error: new Error(`agent ${view.agent} is not registered`) };
404
+ else {
405
+ try {
406
+ outcome = { ok: true, value: toJson(await agent.run(field(started.payload, "input"), context)) };
407
+ } catch (error) {
408
+ outcome = { ok: false, error };
409
+ }
410
+ }
411
+ await writes;
412
+ // A sentinel the agent swallowed still wins: the execution is suspended/cancelled, not finished.
413
+ if (halted || controller.signal.aborted) return halted?.retry ? "retry" : "done";
414
+ if (outcome.ok) await append({ eventType: "run.completed", payload: { output: outcome.value }, metadata: { agent: view.agent } });
415
+ else await append({ eventType: "run.failed", payload: { error: messageOf(outcome.error) }, metadata: { agent: view.agent } });
416
+ return "done";
417
+ } catch (error) {
418
+ if (error instanceof Halt) return error.retry ? "retry" : "done";
419
+ throw error;
420
+ } finally {
421
+ if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
422
+ }
423
+ }
package/src/fold.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Run state is a fold over the run stream (spec §10, §32): nothing returned to callers is a stored copy,
2
+ // so erasing an event payload removes its content from every view derived here.
3
+ import type { Json, StoredEvent } from "@godspeedai/cognate-events";
4
+ import type { RunView } from "@godspeedai/cognate-runtime-api";
5
+
6
+ export const RUN_EXECUTE_TOPIC = "run.execute";
7
+ export const RESUME_HANDLER = { id: "run.resume", version: "1" } as const;
8
+ export const RUNS = "runs";
9
+
10
+ export const runStream = (runId: string) => `run-${runId}`;
11
+ /** Caller-sourced state writes land here, never on the run stream (spec §29.1): a foreign append to the run
12
+ * stream would race the executor's own `expectedVersion` and cause spurious ConcurrencyError/halt churn. */
13
+ export const runStateStream = (runId: string) => `run-state-${runId}`;
14
+ /** Tenant-qualified durable state shared by every run whose correlation id is this thread id. */
15
+ export const threadStateStream = (tenant: string, threadId: string) => `thread-state:${encodeURIComponent(tenant)}:${encodeURIComponent(threadId)}`;
16
+ /**
17
+ * Observations about one world-scoped resource. Keyed by (tenant, worldId, resource) so equal resource strings in
18
+ * different execution worlds are distinct streams — the Phase-2 resource identity invariant at the stream level.
19
+ */
20
+ export const observationStream = (tenant: string, worldId: string | undefined, resource: string) =>
21
+ `observation:${encodeURIComponent(tenant)}:${encodeURIComponent(worldId ?? "-")}:${encodeURIComponent(resource)}`;
22
+
23
+ export type Meta = { readonly [key: string]: Json };
24
+
25
+ export function meta(event: StoredEvent): Meta {
26
+ return event.metadata ?? {};
27
+ }
28
+
29
+ export function field<T extends Json>(value: Json | undefined, key: string): T | null {
30
+ if (value && typeof value === "object" && !Array.isArray(value) && key in value) return value[key] as T;
31
+ return null;
32
+ }
33
+
34
+ const TERMINAL = new Set(["completed", "failed", "cancelled"]);
35
+
36
+ export function isTerminal(view: RunView): boolean {
37
+ return TERMINAL.has(view.status);
38
+ }
39
+
40
+ /** Fold a run stream into its view; `undefined` when the stream is not a run. */
41
+ export function foldRun(runId: string, events: readonly StoredEvent[]): RunView | undefined {
42
+ const first = events[0];
43
+ if (!first || first.eventType !== "run.started") return undefined;
44
+ const m = meta(first);
45
+ let status: RunView["status"] = "running";
46
+ let output: Json = null;
47
+ let error: string | null = null;
48
+ const waiting = new Set<string>();
49
+ for (const event of events) {
50
+ const continuationId = field<string>(meta(event), "continuationId");
51
+ switch (event.eventType) {
52
+ case "run.waiting":
53
+ if (continuationId) waiting.add(continuationId);
54
+ break;
55
+ case "run.resumed":
56
+ case "run.wait.closed":
57
+ if (continuationId) waiting.delete(continuationId);
58
+ break;
59
+ case "run.completed":
60
+ status = "completed";
61
+ output = field(event.payload, "output");
62
+ break;
63
+ case "run.failed":
64
+ status = "failed";
65
+ error = field<string>(event.payload, "error");
66
+ break;
67
+ case "run.cancelled":
68
+ status = "cancelled";
69
+ break;
70
+ }
71
+ }
72
+ if (!TERMINAL.has(status)) status = waiting.size > 0 ? "waiting" : "running";
73
+ else waiting.clear();
74
+ return {
75
+ runId,
76
+ agent: field<string>(m, "agent") ?? "",
77
+ tenant: first.tenant,
78
+ status,
79
+ input: field(first.payload, "input"),
80
+ output,
81
+ error,
82
+ correlationId: first.correlationId,
83
+ startedBy: field<string>(m, "startedBy") ?? "",
84
+ waitingOn: [...waiting],
85
+ };
86
+ }
87
+
88
+ /** Coerce provider/agent values into JSON (drops functions and undefined, like the wire would). */
89
+ export function toJson(value: unknown): Json {
90
+ if (value === undefined) return null;
91
+ return JSON.parse(JSON.stringify(value)) as Json;
92
+ }
93
+
94
+ export function messageOf(error: unknown): string {
95
+ return error instanceof Error ? error.message : String(error);
96
+ }
@@ -0,0 +1,35 @@
1
+ // Governance configuration from the environment. Opt-in and explicit: nothing set means `off`, and a
2
+ // partially or wrongly set environment is an error, never a silent fallback to a weaker mode.
3
+ import { isWorldRef, type GovernanceOptions, timeoutSecsEnforcer } from "./governed-operation.ts";
4
+ import { SeaForgeAuthority } from "./sea-forge-authority.ts";
5
+
6
+ export const ENV_MODE = "COGNATE_GOVERNANCE";
7
+ export const ENV_SOCKET = "COGNATE_SEA_FORGE_SOCKET";
8
+ export const ENV_WORLD = "COGNATE_WORLD_REF";
9
+ export const ENV_ACTOR = "COGNATE_SEA_FORGE_ACTOR";
10
+
11
+ /**
12
+ * `COGNATE_GOVERNANCE=sea-forge` needs `COGNATE_SEA_FORGE_SOCKET` and `COGNATE_WORLD_REF`
13
+ * (`COGNATE_SEA_FORGE_ACTOR` optionally names the service identity, default `cognate-service`).
14
+ * `off` or unset gives `{ mode: "off" }`. Anything else throws.
15
+ */
16
+ export function governanceFromEnv(env: Readonly<Record<string, string | undefined>>): GovernanceOptions {
17
+ const mode = env[ENV_MODE]?.trim();
18
+ if (mode === undefined || mode === "" || mode === "off") {
19
+ const stray = [ENV_SOCKET, ENV_WORLD, ENV_ACTOR].filter((name) => env[name] !== undefined && env[name] !== "");
20
+ if (stray.length) throw new Error(`${stray.join(", ")} set but ${ENV_MODE} is not 'sea-forge'; refusing to guess`);
21
+ return { mode: "off" };
22
+ }
23
+ if (mode !== "sea-forge") throw new Error(`${ENV_MODE} must be 'sea-forge' or 'off', got '${mode}'`);
24
+ const socketPath = env[ENV_SOCKET]?.trim();
25
+ if (!socketPath) throw new Error(`${ENV_SOCKET} is required when ${ENV_MODE}=sea-forge`);
26
+ const worldRef = env[ENV_WORLD]?.trim();
27
+ if (!isWorldRef(worldRef)) throw new Error(`${ENV_WORLD} must be world:<name>@sha256:<64 hex>`);
28
+ const actorId = env[ENV_ACTOR]?.trim();
29
+ return {
30
+ mode: "sea-forge",
31
+ worldRef,
32
+ authority: new SeaForgeAuthority({ socketPath, ...(actorId ? { actor: { actor_id: actorId, role: "service" } } : {}) }),
33
+ enforcers: { timeout_secs: timeoutSecsEnforcer },
34
+ };
35
+ }