@ziggs-ai/agent-sdk 0.1.3 → 0.1.5

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.
Files changed (98) hide show
  1. package/README.md +3 -1
  2. package/package.json +9 -4
  3. package/src/AgentHost.ts +495 -0
  4. package/src/adapters/OpenAIAdapter.ts +146 -0
  5. package/src/agent/Agent.ts +101 -0
  6. package/src/cognition/validateContext.ts +95 -0
  7. package/src/context/applyEffects.ts +80 -0
  8. package/src/context/batch.ts +17 -0
  9. package/src/context/classifyEnvelope.ts +38 -0
  10. package/src/context/routingLabels.ts +46 -0
  11. package/src/defineAgent.ts +62 -0
  12. package/src/formatters/AgreementFormatter.ts +111 -0
  13. package/src/formatters/HistoryFormatter.ts +166 -0
  14. package/src/formatters/index.ts +2 -0
  15. package/src/index.ts +105 -0
  16. package/src/ingress/normalizeIncoming.ts +162 -0
  17. package/src/memory/MemoryStore.ts +104 -0
  18. package/src/pricing/fleetDefaults.ts +218 -0
  19. package/src/pricing/fleetEvalFree.ts +24 -0
  20. package/src/pricing/fleetFreeTierA.gen.ts +12 -0
  21. package/src/pricing/fleetTierByAgentId.gen.ts +1022 -0
  22. package/src/runtime/AgentMachine.ts +364 -0
  23. package/src/runtime/PromptBuilder.ts +463 -0
  24. package/src/runtime/buildOutcome.ts +518 -0
  25. package/src/runtime/defaults.ts +75 -0
  26. package/src/runtime/runTurn.ts +691 -0
  27. package/src/runtime/validateWorkflow.ts +181 -0
  28. package/src/server/ConnectionPool.ts +155 -0
  29. package/src/server/EventQueue.ts +133 -0
  30. package/src/server/InboxCatchUp.ts +251 -0
  31. package/src/server/OutboxBuffer.ts +90 -0
  32. package/src/server/SeenMessages.ts +27 -0
  33. package/src/server/ZiggsEffectHandler.ts +409 -0
  34. package/src/server/agreements/AgreementService.ts +117 -0
  35. package/src/server/createHealthServer.ts +85 -0
  36. package/src/server/proactive/ProactiveTrigger.ts +83 -0
  37. package/src/server/runLauncher.ts +146 -0
  38. package/src/server/tasks/TaskService.ts +110 -0
  39. package/src/server/tasks/index.ts +1 -0
  40. package/src/server/telemetryIngest.ts +91 -0
  41. package/src/server/tools/index.ts +46 -0
  42. package/src/server/tools/tier1/protocolRunner.ts +133 -0
  43. package/src/server/tools/tier1/protocolTools.ts +99 -0
  44. package/src/server/tools/tier2/connectionTools.ts +75 -0
  45. package/src/server/tools/tier2/contextTools.ts +74 -0
  46. package/src/server/tools/tier2/discoveryTools.ts +34 -0
  47. package/src/server/tools/tier2/marketplaceTools.ts +25 -0
  48. package/src/server/tools/tier2/paymentTools.ts +193 -0
  49. package/src/server/ziggsconnect/ZiggsConnectClient.ts +126 -0
  50. package/src/server/ziggscontext/ZiggsContextClient.ts +137 -0
  51. package/src/server/ziggspay/ZiggsPayClient.ts +193 -0
  52. package/src/shared/ids.ts +3 -0
  53. package/src/shared/runtimeLog.ts +72 -0
  54. package/src/shared/types.ts +29 -0
  55. package/src/tasks/protocolRegistry.ts +25 -0
  56. package/src/tools/ToolManager.ts +95 -0
  57. package/src/tools/{ToolProvider.js → ToolProvider.ts} +5 -15
  58. package/src/tools/defineTool.ts +90 -0
  59. package/src/tools/index.ts +5 -0
  60. package/src/types.ts +407 -0
  61. package/src/utils/jsonExtractor.ts +100 -0
  62. package/src/ConnectionPool.js +0 -133
  63. package/src/adapters/OpenAIAdapter.js +0 -73
  64. package/src/agent/Agent.js +0 -121
  65. package/src/agent/EventQueue.js +0 -68
  66. package/src/agent/OutboxBuffer.js +0 -62
  67. package/src/cognition/PromptBuilder.js +0 -312
  68. package/src/cognition/resolveActionTool.js +0 -12
  69. package/src/cognition/runTurn.js +0 -578
  70. package/src/context/applyEffects.js +0 -133
  71. package/src/context/batch.js +0 -25
  72. package/src/context/classifyEnvelope.js +0 -82
  73. package/src/context/routingLabels.js +0 -54
  74. package/src/createHealthServer.js +0 -28
  75. package/src/formatters/HistoryFormatter.js +0 -257
  76. package/src/formatters/TaskFormatter.js +0 -180
  77. package/src/formatters/index.js +0 -9
  78. package/src/index.js +0 -76
  79. package/src/ingress/normalizeIncoming.js +0 -70
  80. package/src/runLauncher.js +0 -159
  81. package/src/shared/ids.js +0 -7
  82. package/src/shared/types.js +0 -86
  83. package/src/tasks/TaskService.js +0 -247
  84. package/src/tasks/index.js +0 -9
  85. package/src/tasks/taskCore.js +0 -229
  86. package/src/tasks/taskProtocolRegistry.js +0 -22
  87. package/src/tasks/taskProtocolRunner.js +0 -107
  88. package/src/tasks/taskProtocolTools.js +0 -87
  89. package/src/tools/ToolManager.js +0 -79
  90. package/src/tools/defineTool.js +0 -82
  91. package/src/tools/index.js +0 -11
  92. package/src/utils/jsonExtractor.js +0 -139
  93. package/src/workflow/AgentMachine.js +0 -250
  94. package/src/workflow/WorkflowRuntime.js +0 -63
  95. package/src/workflow/dsl.js +0 -287
  96. package/src/workflow/motifs.js +0 -435
  97. package/src/ziggs/runtime.js +0 -192
  98. /package/src/adapters/{index.js → index.ts} +0 -0
@@ -0,0 +1,181 @@
1
+ import type { Ctx, OutcomeKind, Workflow, ThinkingState } from '../types.js';
2
+
3
+ const PROBE_CTX: Ctx = {
4
+ identity: { agentId: '', sessionId: '', laneKey: '' },
5
+ activeAgreementId: null,
6
+ activeTaskId: null,
7
+ pendingProposalAgreementId: null,
8
+ delegatedAgreementIds: [],
9
+ delegatedTaskIds: [],
10
+ toolResults: [],
11
+ lastOutcome: { kind: 'enter' },
12
+ };
13
+
14
+ const KNOWN_OUTCOME_KINDS = new Set<OutcomeKind>([
15
+ 'enter',
16
+ 'wait',
17
+ 'timeout',
18
+ 'message-sent',
19
+ 'message-received',
20
+ 'task-assigned',
21
+ 'task-delegated',
22
+ 'task-closed',
23
+ 'subtask-finished',
24
+ 'proposal-made',
25
+ 'proposal-resolved',
26
+ 'tool-result',
27
+ 'error',
28
+ 'extension',
29
+ ]);
30
+
31
+ export class WorkflowValidationError extends Error {
32
+ readonly issues: string[];
33
+ constructor(issues: string[]) {
34
+ super(`Workflow validation failed:\n - ${issues.join('\n - ')}`);
35
+ this.name = 'WorkflowValidationError';
36
+ this.issues = issues;
37
+ }
38
+ }
39
+
40
+ export interface ValidationResult {
41
+ errors: string[];
42
+ warnings: string[];
43
+ }
44
+
45
+ /**
46
+ * Synchronous structural validation. Errors throw; warnings are returned for
47
+ * the caller to log. defineAgent runs this on construction, so misconfigured
48
+ * workflows fail loudly at load instead of mysteriously misbehaving at runtime.
49
+ */
50
+ export function validateWorkflow(workflow: Workflow): ValidationResult {
51
+ const errors: string[] = [];
52
+ const warnings: string[] = [];
53
+
54
+ if (!workflow || typeof workflow !== 'object') {
55
+ throw new WorkflowValidationError(['workflow must be an object']);
56
+ }
57
+ if (!workflow.id) errors.push('workflow.id is required');
58
+ if (!workflow.initial) errors.push('workflow.initial is required');
59
+ if (!workflow.states || typeof workflow.states !== 'object') {
60
+ errors.push('workflow.states is required');
61
+ }
62
+
63
+ if (errors.length) throw new WorkflowValidationError(errors);
64
+
65
+ const stateNames = new Set(Object.keys(workflow.states));
66
+ if (!stateNames.has(workflow.initial)) {
67
+ errors.push(`workflow.initial "${workflow.initial}" is not a defined state`);
68
+ }
69
+
70
+ for (const [name, state] of Object.entries(workflow.states)) {
71
+ if (!state || typeof state !== 'object') {
72
+ errors.push(`state "${name}" is not an object`);
73
+ continue;
74
+ }
75
+ if (state.kind !== 'parked' && state.kind !== 'thinking') {
76
+ errors.push(`state "${name}" must declare kind: 'parked' | 'thinking'`);
77
+ continue;
78
+ }
79
+
80
+ // Shape rejects mixing
81
+ if (state.kind === 'parked') {
82
+ if ((state as unknown as ThinkingState).prompt) {
83
+ errors.push(`state "${name}" is parked but has a prompt — parked states are event-driven only`);
84
+ }
85
+ if ((state as unknown as ThinkingState).actions) {
86
+ errors.push(`state "${name}" is parked but has actions — parked states are event-driven only`);
87
+ }
88
+ }
89
+ if (state.kind === 'thinking') {
90
+ if (!state.prompt) errors.push(`thinking state "${name}" requires a prompt`);
91
+ if (!state.actions || Object.keys(state.actions).length === 0) {
92
+ errors.push(`thinking state "${name}" requires at least one action`);
93
+ }
94
+ }
95
+
96
+ if (!Array.isArray(state.transitions)) {
97
+ errors.push(`state "${name}" requires a transitions array`);
98
+ continue;
99
+ }
100
+
101
+ for (let i = 0; i < state.transitions.length; i++) {
102
+ const t = state.transitions[i];
103
+ if (!t || typeof t !== 'object') {
104
+ errors.push(`state "${name}" transition[${i}] is not an object`);
105
+ continue;
106
+ }
107
+ if (typeof t.to !== 'string') {
108
+ errors.push(`state "${name}" transition[${i}] is missing string "to"`);
109
+ continue;
110
+ }
111
+ if (!stateNames.has(t.to)) {
112
+ errors.push(`state "${name}" transition[${i}] points to unknown state "${t.to}"`);
113
+ }
114
+ if (t.when !== undefined && typeof t.when !== 'function') {
115
+ errors.push(`state "${name}" transition[${i}].when must be a function or omitted`);
116
+ }
117
+ }
118
+
119
+ if (state.kind === 'thinking') {
120
+ for (const [actionName, action] of Object.entries(state.actions)) {
121
+ if (!action || typeof action !== 'object') {
122
+ errors.push(`state "${name}" action "${actionName}" is not an object`);
123
+ continue;
124
+ }
125
+ if (!action.prompt || !action.prompt.instruction) {
126
+ errors.push(`state "${name}" action "${actionName}" requires prompt.instruction`);
127
+ }
128
+ const p = action.produces;
129
+ if (typeof p === 'string') {
130
+ if (!KNOWN_OUTCOME_KINDS.has(p as OutcomeKind)) {
131
+ errors.push(
132
+ `state "${name}" action "${actionName}" produces unknown outcome kind "${p}" — use one of: ${[...KNOWN_OUTCOME_KINDS].join(', ')}`,
133
+ );
134
+ }
135
+ } else if (typeof p !== 'function') {
136
+ errors.push(`state "${name}" action "${actionName}" requires produces (OutcomeKind or fn)`);
137
+ }
138
+ }
139
+
140
+ // Soft check: thinking state should have a wait action and a fallthrough.
141
+ // Authors can opt out via allowNoWait when intentionally building a
142
+ // looping or always-progressing state.
143
+ if (!state.allowNoWait) {
144
+ if (!state.actions?.wait) {
145
+ warnings.push(
146
+ `thinking state "${name}" has no "wait" action — did you forget to spread thinkingDefaults({ initial: '${workflow.initial}' })? Set allowNoWait: true to silence.`,
147
+ );
148
+ }
149
+ const hasWaitFallthrough = state.transitions.some(t => {
150
+ if (!t.when) return true;
151
+ try { return t.when({ kind: 'wait' }, PROBE_CTX); }
152
+ catch { return false; }
153
+ });
154
+ if (!hasWaitFallthrough) {
155
+ warnings.push(
156
+ `thinking state "${name}" has no fallthrough for { kind: 'wait' } — agent may stall when LLM picks wait. Spread thinkingDefaults().transitions or add explicitly.`,
157
+ );
158
+ }
159
+ const hasErrorFallthrough = state.transitions.some(t => {
160
+ if (!t.when) return true;
161
+ try {
162
+ return t.when(
163
+ { kind: 'error', source: 'unknown', cause: 'probe', retryable: false },
164
+ PROBE_CTX,
165
+ );
166
+ } catch {
167
+ return false;
168
+ }
169
+ });
170
+ if (!hasErrorFallthrough) {
171
+ warnings.push(
172
+ `thinking state "${name}" has no fallthrough for { kind: 'error' } — empty/failed turns may silently recurse until the depth guard (ZIG-303). Add an error transition or spread thinkingDefaults().transitions.`,
173
+ );
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ if (errors.length) throw new WorkflowValidationError(errors);
180
+ return { errors, warnings };
181
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * ConnectionPool — multi-agent fleet manager.
3
+ *
4
+ * Holds N `AgentHostOptions` configs but only constructs/wakes an
5
+ * `AgentHost` (one WS connection) when needed. Idle hosts are slept after
6
+ * `idleTimeoutMs`. At most `maxActive` hosts are connected at a time; LRU
7
+ * eviction drops the oldest if the cap is reached. With `startControl`, a
8
+ * separate "launcher" socket receives backend-driven wake hints so the pool
9
+ * spins up the right host on demand.
10
+ *
11
+ * This is how a single Node process serves a fleet of agents without
12
+ * holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
13
+ */
14
+ import { ConnectionManager } from '@ziggs-ai/api-client';
15
+ import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
16
+ import { runtimeLog } from '../shared/runtimeLog.js';
17
+
18
+ // Inlined from api-client (not re-exported there). Sync if the upstream
19
+ // types change.
20
+ interface ConnectionManagerMeta {
21
+ domain?: string;
22
+ expertise?: string[];
23
+ tags?: string[];
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface QueryFilter {
28
+ domain?: string;
29
+ expertise?: string[];
30
+ tags?: string[];
31
+ }
32
+
33
+ export interface PoolOptions {
34
+ maxActive?: number;
35
+ idleTimeoutMs?: number;
36
+ }
37
+
38
+ export interface SendToOptions {
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ export interface ControlOptions {
43
+ wsUrl?: string;
44
+ operatorKey?: string;
45
+ }
46
+
47
+ export class ConnectionPool {
48
+ private readonly manager: ConnectionManager;
49
+ private readonly configs = new Map<string, AgentHostOptions>();
50
+
51
+ constructor({ maxActive = 50, idleTimeoutMs = 60_000 }: PoolOptions = {}) {
52
+ this.manager = new ConnectionManager({ maxActive, idleTimeoutMs });
53
+ }
54
+
55
+ /**
56
+ * Register N agent configs. Each will be lazily woken (a fresh
57
+ * `AgentHost` constructed + connected) the first time it's referenced.
58
+ */
59
+ register(configs: AgentHostOptions[], metaArr: ConnectionManagerMeta[] = []): void {
60
+ const metaByAgentId = new Map<string, ConnectionManagerMeta>();
61
+ for (const m of metaArr) {
62
+ const id = m['agentId'] as string | undefined;
63
+ if (id) metaByAgentId.set(id, m);
64
+ }
65
+ for (const config of configs) {
66
+ const agentId = config.agentId ?? config.workflow?.id;
67
+ if (!agentId) {
68
+ runtimeLog.warn('ConnectionPool', 'skipping config with no resolvable agentId');
69
+ continue;
70
+ }
71
+ this.configs.set(agentId, config);
72
+ this.manager.register(
73
+ agentId,
74
+ async () => {
75
+ const host = new AgentHost(this.configs.get(agentId)!);
76
+ await host.connectAsync();
77
+ runtimeLog.debug(
78
+ 'ConnectionPool',
79
+ `woke "${agentId}" (active: ${this.manager.listActive().length}/${this.maxActive})`,
80
+ );
81
+ return host;
82
+ },
83
+ async (host) => {
84
+ (host as AgentHost).disconnect();
85
+ runtimeLog.debug('ConnectionPool', `slept "${agentId}"`);
86
+ },
87
+ metaByAgentId.get(agentId),
88
+ );
89
+ }
90
+ }
91
+
92
+ async wake(agentId: string): Promise<AgentHost> {
93
+ return await this.manager.wake(agentId) as AgentHost;
94
+ }
95
+
96
+ async sleep(agentId: string): Promise<void> {
97
+ return this.manager.sleep(agentId);
98
+ }
99
+
100
+ async disconnectAll(): Promise<void> {
101
+ return this.manager.sleepAll();
102
+ }
103
+
104
+ /**
105
+ * Wake the named agent and deliver one wire frame to it. Returns once the
106
+ * host has finished processing.
107
+ *
108
+ * Note: does not return the agent's response. Capturing responses
109
+ * synchronously required internal-buffer poking the Agent/Server split
110
+ * removed. For request/response semantics, run the agent under an
111
+ * AgentHost with an injected EffectHandler that records sends.
112
+ */
113
+ async sendTo(agentId: string, text: string, metadata: Record<string, unknown> = {}, { timeoutMs = 30_000 }: SendToOptions = {}): Promise<null> {
114
+ const host = await this.wake(agentId);
115
+ const timeoutPromise = new Promise<never>((_, reject) =>
116
+ setTimeout(() => reject(new Error(`sendTo timeout for "${agentId}" after ${timeoutMs}ms`)), timeoutMs),
117
+ );
118
+ await Promise.race([host.handleMessage(text, metadata), timeoutPromise]);
119
+ this.manager.touch(agentId);
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Open a launcher (control) socket that the backend uses to push wake
125
+ * hints for any of the registered agents. Pool spins up the right host
126
+ * lazily when a hint arrives.
127
+ */
128
+ startControl({ wsUrl, operatorKey }: ControlOptions = {}): void {
129
+ if (!wsUrl || !operatorKey) {
130
+ runtimeLog.warn(
131
+ 'ConnectionPool',
132
+ 'startControl: wsUrl and operatorKey are required — skipping',
133
+ );
134
+ return;
135
+ }
136
+ // ConnectionManager only accepts `control` at construction; mutating its
137
+ // private field is the smallest way to add it later. (Cleaner fix lives
138
+ // in api-client: expose a `setControl` setter.)
139
+ (this.manager as unknown as Record<string, unknown>)['_controlOpts'] = { wsUrl, operatorKey };
140
+ this.manager.start();
141
+ }
142
+
143
+ stopControl(): void {
144
+ const m = this.manager as unknown as Record<string, unknown>;
145
+ (m['_controlHandle'] as { close?: () => void } | null)?.close?.();
146
+ m['_controlHandle'] = null;
147
+ }
148
+
149
+ query(filter: QueryFilter): string[] { return this.manager.query(filter); }
150
+ list(): string[] { return this.manager.list(); }
151
+ listActive(): string[] { return this.manager.listActive(); }
152
+ get size(): number { return this.manager.size; }
153
+ get maxActive(): number { return (this.manager as unknown as { maxActive: number }).maxActive; }
154
+ getMeta(id: string): ConnectionManagerMeta | undefined { return this.manager.getMeta(id); }
155
+ }
@@ -0,0 +1,133 @@
1
+ import { runtimeLog } from '../shared/runtimeLog.js';
2
+
3
+ export type ProcessEventFn = (event: unknown, laneKey: string) => Promise<void>;
4
+
5
+ interface QueuedEntry {
6
+ event: unknown;
7
+ resolve: () => void;
8
+ }
9
+
10
+ interface LaneState {
11
+ events: QueuedEntry[];
12
+ processing: boolean;
13
+ }
14
+
15
+ interface QueueMetrics {
16
+ enqueued: number;
17
+ dropped: number;
18
+ processedBatches: number;
19
+ processedEvents: number;
20
+ errors: number;
21
+ }
22
+
23
+ export interface EventQueueOptions {
24
+ maxQueueLengthPerLane?: number;
25
+ }
26
+
27
+ /**
28
+ * Serializes events per lane and coalesces multiple queued events into one
29
+ * batch dispatch. The runtime sees at most one in-flight processEvent call
30
+ * per laneKey at a time; multiple events arriving while busy are merged
31
+ * into a single { type: 'batch', events: [...] } frame on the next pass.
32
+ */
33
+ export class EventQueue {
34
+ private _processEvent: ProcessEventFn;
35
+ private _maxQueueLengthPerLane: number;
36
+ private _state: Map<string, LaneState> = new Map();
37
+ private _metrics: QueueMetrics = {
38
+ enqueued: 0, dropped: 0, processedBatches: 0, processedEvents: 0, errors: 0,
39
+ };
40
+
41
+ constructor(processEventFn: ProcessEventFn, options: EventQueueOptions = {}) {
42
+ this._processEvent = processEventFn;
43
+ this._maxQueueLengthPerLane = Number.isFinite(options.maxQueueLengthPerLane)
44
+ ? Math.max(1, options.maxQueueLengthPerLane!)
45
+ : 100;
46
+ }
47
+
48
+ enqueue(event: unknown, laneKey: string): Promise<void> {
49
+ if (!laneKey) {
50
+ runtimeLog.warn('EventQueue', 'enqueue called without laneKey, skipping');
51
+ return Promise.resolve();
52
+ }
53
+
54
+ let state = this._state.get(laneKey);
55
+ if (!state) {
56
+ state = { events: [], processing: false };
57
+ this._state.set(laneKey, state);
58
+ }
59
+
60
+ return new Promise<void>((resolve) => {
61
+ this._metrics.enqueued += 1;
62
+ state!.events.push({ event, resolve });
63
+ this._trimQueueIfNeeded(state!, laneKey);
64
+ this._processIfNeeded(laneKey);
65
+ });
66
+ }
67
+
68
+ private _processIfNeeded(laneKey: string): void {
69
+ const state = this._state.get(laneKey);
70
+ if (!state || state.processing || state.events.length === 0) return;
71
+ state.processing = true;
72
+ this._processLoop(laneKey);
73
+ }
74
+
75
+ private async _processLoop(laneKey: string): Promise<void> {
76
+ const state = this._state.get(laneKey);
77
+ if (!state) return;
78
+
79
+ while (state.events.length > 0) {
80
+ const batch = state.events.splice(0, state.events.length);
81
+ const events = batch.map(e => e.event);
82
+ const resolvers = batch.map(e => e.resolve);
83
+
84
+ const coalesced = events.length === 1
85
+ ? events[0]
86
+ : { type: 'batch', events };
87
+
88
+ try {
89
+ await this._processEvent(coalesced, laneKey);
90
+ } catch (error: unknown) {
91
+ this._metrics.errors += 1;
92
+ runtimeLog.error('EventQueue', `processEvent error for laneKey=${laneKey}: ${(error as Error)?.message || error}`);
93
+ }
94
+ this._metrics.processedBatches += 1;
95
+ this._metrics.processedEvents += events.length;
96
+
97
+ for (const r of resolvers) r();
98
+ }
99
+
100
+ state.processing = false;
101
+ }
102
+
103
+ private _trimQueueIfNeeded(state: LaneState, laneKey: string): void {
104
+ while (state.events.length > this._maxQueueLengthPerLane) {
105
+ const dropped = state.events.shift();
106
+ this._metrics.dropped += 1;
107
+ dropped?.resolve();
108
+ runtimeLog.warn('EventQueue', `dropped oldest event laneKey=${laneKey} max=${this._maxQueueLengthPerLane}`);
109
+ }
110
+ }
111
+
112
+ get isIdle(): boolean {
113
+ return [...this._state.values()].every(s => !s.processing && s.events.length === 0);
114
+ }
115
+
116
+ /** Resolves when all lanes finish processing, or after timeoutMs. Best-effort — never throws. */
117
+ async waitForIdle(timeoutMs = 30_000): Promise<void> {
118
+ const deadline = Date.now() + timeoutMs;
119
+ while (Date.now() < deadline) {
120
+ if (this.isIdle) return;
121
+ await new Promise<void>(r => setTimeout(r, 50));
122
+ }
123
+ runtimeLog.warn('EventQueue', `waitForIdle timed out after ${timeoutMs}ms`);
124
+ }
125
+
126
+ getMetrics() {
127
+ return {
128
+ ...this._metrics,
129
+ lanes: this._state.size,
130
+ queuedEvents: [...this._state.values()].reduce((sum, lane) => sum + lane.events.length, 0),
131
+ };
132
+ }
133
+ }