Skip to content

Commit 67bb486

Browse files
committed
feat: events, signals, observability packages — Prompt 4
Signed-off-by: Todd Palmer <todd@betterdata.co>
1 parent d45a272 commit 67bb486

24 files changed

Lines changed: 935 additions & 65 deletions

packages/events/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
},
1616
"scripts": {
1717
"build": "tsc -p tsconfig.json",
18-
"typecheck": "tsc -p tsconfig.json --noEmit"
18+
"typecheck": "tsc -p tsconfig.json --noEmit",
19+
"test": "vitest run"
1920
},
2021
"dependencies": {
21-
"@loopengine/core": "workspace:*"
22+
"@loopengine/core": "workspace:*",
23+
"zod": "^3.22.0"
2224
}
2325
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import { describe, expect, it } from "vitest";
4+
import { LoopEventSchema } from "../schemas";
5+
import { extractLearningSignal } from "../types";
6+
7+
describe("events package", () => {
8+
it("parses valid loop.started event", () => {
9+
const result = LoopEventSchema.safeParse({
10+
type: "loop.started",
11+
eventId: "e1",
12+
loopId: "demo.loop",
13+
aggregateId: "A-1",
14+
orgId: "acme",
15+
occurredAt: new Date().toISOString(),
16+
correlationId: "corr-1",
17+
initialState: "OPEN",
18+
actor: { type: "human", id: "user@example.com" }
19+
});
20+
expect(result.success).toBe(true);
21+
});
22+
23+
it("extractLearningSignal computes numeric delta", () => {
24+
const completed = {
25+
type: "loop.completed",
26+
eventId: "e2",
27+
loopId: "demo.loop",
28+
aggregateId: "A-1",
29+
orgId: "acme",
30+
occurredAt: "2026-01-04T00:00:00.000Z",
31+
correlationId: "corr-1",
32+
terminalState: "DONE",
33+
actor: { type: "human", id: "user@example.com" },
34+
durationMs: 1,
35+
transitionCount: 2,
36+
outcomeId: "done",
37+
valueUnit: "done"
38+
} as const;
39+
const result = extractLearningSignal(completed as any, [
40+
{
41+
occurredAt: "2026-01-01T00:00:00.000Z"
42+
} as any
43+
], {
44+
cycle_time_days: 2
45+
});
46+
expect(typeof result.delta.cycle_time_days).toBe("number");
47+
});
48+
});

packages/events/src/bus.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import type { LoopEvent } from "./types";
4+
5+
type Handler = (event: LoopEvent) => Promise<void>;
6+
7+
export class InMemoryEventBus {
8+
private handlers = new Set<Handler>();
9+
10+
async emit(event: LoopEvent): Promise<void> {
11+
for (const h of this.handlers) {
12+
try {
13+
await h(event);
14+
} catch {
15+
// Handler errors should not block emission.
16+
}
17+
}
18+
}
19+
20+
subscribe(handler: Handler): () => void {
21+
this.handlers.add(handler);
22+
return () => this.handlers.delete(handler);
23+
}
24+
}

packages/events/src/index.ts

Lines changed: 3 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,6 @@
11
// @license MIT
22
// SPDX-License-Identifier: MIT
3-
import type {
4-
ActorRef,
5-
AggregateId,
6-
CorrelationId,
7-
Evidence,
8-
GuardId,
9-
LoopId,
10-
OutcomeId,
11-
StateId,
12-
TransitionId
13-
} from "@loopengine/core";
14-
15-
export interface LoopEventBase {
16-
eventId: string;
17-
loopId: LoopId;
18-
aggregateId: AggregateId;
19-
orgId: string;
20-
occurredAt: string;
21-
correlationId: CorrelationId;
22-
causationId?: string;
23-
}
24-
25-
export interface LoopStartedEvent extends LoopEventBase {
26-
type: "loop.started";
27-
initialState: StateId;
28-
actor: ActorRef;
29-
}
30-
31-
export interface TransitionExecutedEvent extends LoopEventBase {
32-
type: "loop.transition.executed";
33-
fromState: StateId;
34-
toState: StateId;
35-
transitionId: TransitionId;
36-
actor: ActorRef;
37-
evidence: Evidence;
38-
durationMs?: number;
39-
}
40-
41-
export interface GuardFailedEvent extends LoopEventBase {
42-
type: "loop.guard.failed";
43-
fromState: StateId;
44-
attemptedTransitionId: TransitionId;
45-
guardId: GuardId;
46-
guardFailureMessage: string;
47-
severity?: "hard" | "soft";
48-
actor: ActorRef;
49-
}
50-
51-
export interface LoopCompletedEvent extends LoopEventBase {
52-
type: "loop.completed";
53-
terminalState: StateId;
54-
actor: ActorRef;
55-
durationMs: number;
56-
transitionCount: number;
57-
outcomeId: OutcomeId;
58-
valueUnit: string;
59-
}
60-
61-
export type LoopEvent =
62-
| LoopStartedEvent
63-
| TransitionExecutedEvent
64-
| GuardFailedEvent
65-
| LoopCompletedEvent;
3+
export * from "./types";
4+
export * from "./schemas";
5+
export * from "./bus";
666

packages/events/src/schemas.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import { z } from "zod";
4+
5+
const BaseSchema = z.object({
6+
eventId: z.string().min(1),
7+
loopId: z.string().min(1),
8+
aggregateId: z.string().min(1),
9+
orgId: z.string().min(1),
10+
occurredAt: z.string().min(1),
11+
correlationId: z.string().min(1),
12+
causationId: z.string().optional()
13+
});
14+
15+
const ActorSchema = z.object({
16+
type: z.enum(["human", "automation", "ai-agent", "webhook", "system"]),
17+
id: z.string().min(1)
18+
});
19+
20+
const EvidenceSchema = z.record(z.unknown());
21+
22+
export const LoopStartedEventSchema = BaseSchema.extend({
23+
type: z.literal("loop.started"),
24+
initialState: z.string().min(1),
25+
actor: ActorSchema
26+
});
27+
28+
export const TransitionRequestedEventSchema = BaseSchema.extend({
29+
type: z.literal("loop.transition.requested"),
30+
transitionId: z.string().min(1),
31+
actor: ActorSchema,
32+
evidence: EvidenceSchema,
33+
requestedAt: z.string().min(1)
34+
});
35+
36+
export const TransitionExecutedEventSchema = BaseSchema.extend({
37+
type: z.literal("loop.transition.executed"),
38+
fromState: z.string().min(1),
39+
toState: z.string().min(1),
40+
transitionId: z.string().min(1),
41+
actor: ActorSchema,
42+
evidence: EvidenceSchema,
43+
durationMs: z.number().optional()
44+
});
45+
46+
export const TransitionBlockedEventSchema = BaseSchema.extend({
47+
type: z.literal("loop.transition.blocked"),
48+
transitionId: z.string().min(1),
49+
reason: z.enum(["guard_failed", "unauthorized_actor", "invalid_transition", "loop_closed"]),
50+
actor: ActorSchema,
51+
guardFailures: z.array(z.object({ guardId: z.string(), message: z.string() })).optional()
52+
});
53+
54+
export const GuardFailedEventSchema = BaseSchema.extend({
55+
type: z.literal("loop.guard.failed"),
56+
fromState: z.string().min(1),
57+
attemptedTransitionId: z.string().min(1),
58+
guardId: z.string().min(1),
59+
guardFailureMessage: z.string().min(1),
60+
severity: z.enum(["hard", "soft"]).optional(),
61+
actor: ActorSchema
62+
});
63+
64+
export const LoopCompletedEventSchema = BaseSchema.extend({
65+
type: z.literal("loop.completed"),
66+
terminalState: z.string().min(1),
67+
actor: ActorSchema,
68+
durationMs: z.number(),
69+
transitionCount: z.number().int().nonnegative(),
70+
outcomeId: z.string().min(1),
71+
valueUnit: z.string().min(1)
72+
});
73+
74+
export const LoopErrorEventSchema = BaseSchema.extend({
75+
type: z.literal("loop.error"),
76+
errorState: z.string().min(1),
77+
errorCode: z.string().min(1),
78+
errorMessage: z.string().min(1),
79+
actor: ActorSchema
80+
});
81+
82+
export const LoopSpawnedEventSchema = BaseSchema.extend({
83+
type: z.literal("loop.spawned"),
84+
parentAggregateId: z.string().min(1),
85+
childLoopId: z.string().min(1),
86+
childAggregateId: z.string().min(1)
87+
});
88+
89+
export const SignalReceivedEventSchema = BaseSchema.extend({
90+
type: z.literal("loop.signal.received"),
91+
signalType: z.string().min(1),
92+
confidence: z.number(),
93+
triggeredLoopId: z.string().optional()
94+
});
95+
96+
export const OutcomeRecordedEventSchema = BaseSchema.extend({
97+
type: z.literal("loop.outcome.recorded"),
98+
outcomeId: z.string().min(1),
99+
valueUnit: z.string().min(1),
100+
businessMetrics: z.record(z.unknown()).optional(),
101+
durationMs: z.number()
102+
});
103+
104+
export const LoopEventSchema = z.discriminatedUnion("type", [
105+
LoopStartedEventSchema,
106+
TransitionRequestedEventSchema,
107+
TransitionExecutedEventSchema,
108+
TransitionBlockedEventSchema,
109+
GuardFailedEventSchema,
110+
LoopCompletedEventSchema,
111+
LoopErrorEventSchema,
112+
LoopSpawnedEventSchema,
113+
SignalReceivedEventSchema,
114+
OutcomeRecordedEventSchema
115+
]);

0 commit comments

Comments
 (0)