@lumenflow/surfaces 5.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.
Files changed (36) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +40 -0
  3. package/cli/__tests__/gates.test.ts +97 -0
  4. package/cli/__tests__/inspect.test.ts +184 -0
  5. package/cli/__tests__/task-lifecycle.test.ts +203 -0
  6. package/cli/gates.ts +46 -0
  7. package/cli/index.ts +6 -0
  8. package/cli/inspect.ts +138 -0
  9. package/cli/task-lifecycle.ts +46 -0
  10. package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
  11. package/http/__tests__/auth-boundary.test.ts +57 -0
  12. package/http/__tests__/channel-send-governance.test.ts +158 -0
  13. package/http/__tests__/event-stream.test.ts +340 -0
  14. package/http/__tests__/phone-device-tool-api.test.ts +177 -0
  15. package/http/__tests__/remote-exposure.test.ts +212 -0
  16. package/http/__tests__/run-agent.test.ts +447 -0
  17. package/http/__tests__/scope-enforcement.test.ts +349 -0
  18. package/http/__tests__/sidecar-entry.test.ts +158 -0
  19. package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
  20. package/http/__tests__/tool-api.test.ts +491 -0
  21. package/http/__tests__/tool-discovery.test.ts +384 -0
  22. package/http/ag-ui-adapter.ts +352 -0
  23. package/http/auth.ts +294 -0
  24. package/http/control-plane-event-subscriber.ts +233 -0
  25. package/http/event-stream.ts +216 -0
  26. package/http/index.ts +10 -0
  27. package/http/run-agent.ts +416 -0
  28. package/http/server.ts +329 -0
  29. package/http/sidecar-entry.ts +218 -0
  30. package/http/task-api.ts +307 -0
  31. package/http/tool-api.ts +373 -0
  32. package/http/tool-discovery.ts +159 -0
  33. package/mcp/__tests__/server.test.ts +554 -0
  34. package/mcp/index.ts +4 -0
  35. package/mcp/server.ts +250 -0
  36. package/package.json +51 -0
@@ -0,0 +1,233 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { Disposable, KernelEvent, ReplayFilter } from '@lumenflow/kernel';
5
+ import type { EventSubscriber } from './event-stream.js';
6
+
7
+ const CONTROL_PLANE_POLICY_DECISION = {
8
+ DENY: 'deny',
9
+ } as const;
10
+
11
+ const DIAGNOSTIC_REASON = {
12
+ POLICY_PULL_FAILED: 'policy pull failed',
13
+ HEARTBEAT_FAILED: 'heartbeat failed',
14
+ EVENT_PUSH_FAILED: 'event forwarding failed',
15
+ } as const;
16
+
17
+ const ACTIONABLE_SYNC_DIAGNOSTIC_SUFFIX = 'Check control_plane.endpoint and auth token.';
18
+ const WORKSPACE_WARNING_EVENT_KIND = 'workspace_warning';
19
+ const KERNEL_EVENT_SCHEMA_VERSION = 1;
20
+ const DEFAULT_SYNC_INTERVAL_MS = 30_000;
21
+ const SYNC_SESSION_ID_PREFIX = 'http-surface-sync';
22
+ const CLOCK_ISO_OFFSET = 36;
23
+
24
+ interface ControlPlanePolicyRuleLike {
25
+ id: string;
26
+ decision: 'allow' | 'deny';
27
+ reason?: string;
28
+ }
29
+
30
+ interface ControlPlanePolicySetLike {
31
+ default_decision: 'allow' | 'deny';
32
+ rules: ControlPlanePolicyRuleLike[];
33
+ }
34
+
35
+ export interface ControlPlaneSyncPortLike {
36
+ pullPolicies(input: { workspace_id: string }): Promise<ControlPlanePolicySetLike>;
37
+ heartbeat(input: {
38
+ workspace_id: string;
39
+ session_id: string;
40
+ }): Promise<{ status: 'ok'; server_time: string }>;
41
+ pushKernelEvents(input: {
42
+ workspace_id: string;
43
+ events: KernelEvent[];
44
+ }): Promise<{ accepted: number }>;
45
+ }
46
+
47
+ export interface ControlPlaneEventSubscriberOptions {
48
+ controlPlaneSyncPort: ControlPlaneSyncPortLike;
49
+ workspaceId: string;
50
+ pollIntervalMs: number;
51
+ logger?: Pick<Console, 'warn'>;
52
+ now?: () => Date;
53
+ }
54
+
55
+ export const DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MS;
56
+
57
+ interface SyncState {
58
+ allowForwarding: boolean;
59
+ inFlight: boolean;
60
+ }
61
+
62
+ function createWorkspaceWarningEvent(message: string, now: () => Date): KernelEvent {
63
+ return {
64
+ schema_version: KERNEL_EVENT_SCHEMA_VERSION,
65
+ kind: WORKSPACE_WARNING_EVENT_KIND,
66
+ timestamp: now().toISOString(),
67
+ message,
68
+ } as KernelEvent;
69
+ }
70
+
71
+ function createSyncSessionId(now: () => Date): string {
72
+ return `${SYNC_SESSION_ID_PREFIX}:${now().toISOString().slice(0, CLOCK_ISO_OFFSET)}`;
73
+ }
74
+
75
+ async function emitDiagnostic(
76
+ callback: (event: KernelEvent) => void | Promise<void>,
77
+ logger: Pick<Console, 'warn'> | undefined,
78
+ workspaceId: string,
79
+ reason: string,
80
+ now: () => Date,
81
+ ): Promise<void> {
82
+ const message =
83
+ `Control plane sync ${reason} for workspace "${workspaceId}". ` +
84
+ ACTIONABLE_SYNC_DIAGNOSTIC_SUFFIX;
85
+ logger?.warn?.(message);
86
+ await callback(createWorkspaceWarningEvent(message, now));
87
+ }
88
+
89
+ async function runSyncCycle(
90
+ options: ControlPlaneEventSubscriberOptions,
91
+ callback: (event: KernelEvent) => void | Promise<void>,
92
+ sessionId: string,
93
+ pendingEvents: KernelEvent[],
94
+ state: SyncState,
95
+ ): Promise<void> {
96
+ if (state.inFlight) {
97
+ return;
98
+ }
99
+ state.inFlight = true;
100
+ const now = options.now ?? (() => new Date());
101
+
102
+ try {
103
+ try {
104
+ const pulledPolicies = await options.controlPlaneSyncPort.pullPolicies({
105
+ workspace_id: options.workspaceId,
106
+ });
107
+ state.allowForwarding =
108
+ pulledPolicies.default_decision !== CONTROL_PLANE_POLICY_DECISION.DENY;
109
+ } catch {
110
+ state.allowForwarding = false;
111
+ await emitDiagnostic(
112
+ callback,
113
+ options.logger,
114
+ options.workspaceId,
115
+ DIAGNOSTIC_REASON.POLICY_PULL_FAILED,
116
+ now,
117
+ );
118
+ return;
119
+ }
120
+
121
+ try {
122
+ await options.controlPlaneSyncPort.heartbeat({
123
+ workspace_id: options.workspaceId,
124
+ session_id: sessionId,
125
+ });
126
+ } catch {
127
+ await emitDiagnostic(
128
+ callback,
129
+ options.logger,
130
+ options.workspaceId,
131
+ DIAGNOSTIC_REASON.HEARTBEAT_FAILED,
132
+ now,
133
+ );
134
+ return;
135
+ }
136
+
137
+ if (!state.allowForwarding || pendingEvents.length === 0) {
138
+ return;
139
+ }
140
+
141
+ const batch = pendingEvents.splice(0, pendingEvents.length);
142
+ try {
143
+ await options.controlPlaneSyncPort.pushKernelEvents({
144
+ workspace_id: options.workspaceId,
145
+ events: batch,
146
+ });
147
+ } catch {
148
+ pendingEvents.unshift(...batch);
149
+ state.allowForwarding = false;
150
+ await emitDiagnostic(
151
+ callback,
152
+ options.logger,
153
+ options.workspaceId,
154
+ DIAGNOSTIC_REASON.EVENT_PUSH_FAILED,
155
+ now,
156
+ );
157
+ }
158
+ } finally {
159
+ state.inFlight = false;
160
+ }
161
+ }
162
+
163
+ export function wrapEventSubscriberWithControlPlaneSync(
164
+ source: EventSubscriber,
165
+ options: ControlPlaneEventSubscriberOptions,
166
+ ): EventSubscriber {
167
+ const resolvedIntervalMs =
168
+ options.pollIntervalMs > 0 ? options.pollIntervalMs : DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS;
169
+
170
+ return {
171
+ subscribe(
172
+ filter: ReplayFilter,
173
+ callback: (event: KernelEvent) => void | Promise<void>,
174
+ ): Disposable {
175
+ const pendingEvents: KernelEvent[] = [];
176
+ const state: SyncState = {
177
+ allowForwarding: true,
178
+ inFlight: false,
179
+ };
180
+ const now = options.now ?? (() => new Date());
181
+ const sessionId = createSyncSessionId(now);
182
+
183
+ const wrappedCallback = async (event: KernelEvent): Promise<void> => {
184
+ await callback(event);
185
+ pendingEvents.push(event);
186
+ };
187
+
188
+ const sourceSubscription = source.subscribe(filter, wrappedCallback);
189
+ const timer = setInterval(() => {
190
+ void runSyncCycle(options, callback, sessionId, pendingEvents, state);
191
+ }, resolvedIntervalMs);
192
+
193
+ return {
194
+ dispose: () => {
195
+ clearInterval(timer);
196
+ sourceSubscription.dispose();
197
+ },
198
+ };
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Creates an EventSubscriber that can be used by the dashboard to
205
+ * optionally read events from the control plane instead of a local
206
+ * EventStore. This enables centralized observability for organizations
207
+ * where the web dashboard may not have direct access to the local
208
+ * event-store file.
209
+ *
210
+ * Note: This is a push-side bridge. The subscriber interface allows
211
+ * the dashboard to subscribe to events that are forwarded through the
212
+ * control plane, enabling the same SSE streaming pattern as local mode.
213
+ */
214
+ export function createControlPlaneEventSubscriber(
215
+ _options: ControlPlaneEventSubscriberOptions,
216
+ ): EventSubscriber {
217
+ return {
218
+ subscribe(
219
+ _filter: ReplayFilter,
220
+ _callback: (event: KernelEvent) => void | Promise<void>,
221
+ ): Disposable {
222
+ // The control plane event subscriber provides the interface contract
223
+ // for dashboard integration. The actual polling/streaming implementation
224
+ // will be wired when the control plane SDK exposes a pull/subscribe
225
+ // endpoint for kernel events.
226
+ return {
227
+ dispose: () => {
228
+ // Cleanup when subscription is no longer needed.
229
+ },
230
+ };
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,216 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import {
6
+ KERNEL_EVENT_KINDS,
7
+ type Disposable,
8
+ type KernelEvent,
9
+ type ReplayFilter,
10
+ type ToolTraceEntry,
11
+ } from '@lumenflow/kernel';
12
+
13
+ const HTTP_METHOD = {
14
+ GET: 'GET',
15
+ } as const;
16
+
17
+ const HTTP_STATUS = {
18
+ OK: 200,
19
+ NOT_FOUND: 404,
20
+ METHOD_NOT_ALLOWED: 405,
21
+ NOT_IMPLEMENTED: 501,
22
+ } as const;
23
+
24
+ const HEADER = {
25
+ CACHE_CONTROL: 'cache-control',
26
+ CONNECTION: 'connection',
27
+ CONTENT_TYPE: 'content-type',
28
+ } as const;
29
+
30
+ const HEADER_VALUE = {
31
+ CACHE_CONTROL: 'no-cache',
32
+ CONNECTION: 'keep-alive',
33
+ EVENT_STREAM: 'text/event-stream; charset=utf-8',
34
+ } as const;
35
+
36
+ const JSON_RESPONSE_KEY_ERROR = 'error';
37
+ const JSON_RESPONSE_KEY_MESSAGE = 'message';
38
+ const SSE_DATA_PREFIX = 'data: ';
39
+ const SSE_DOUBLE_NEWLINE = '\n\n';
40
+ const SSE_HEARTBEAT_COMMENT = ':heartbeat\n\n';
41
+ const HEARTBEAT_INTERVAL_MS = 15_000;
42
+ const SEARCH_PARAM = {
43
+ KIND: 'kind',
44
+ SINCE_TIMESTAMP: 'sinceTimestamp',
45
+ UNTIL_TIMESTAMP: 'untilTimestamp',
46
+ } as const;
47
+
48
+ const REPLAY_KIND_VALUES = new Set<KernelEvent['kind']>(
49
+ Object.values(KERNEL_EVENT_KINDS) as KernelEvent['kind'][],
50
+ );
51
+
52
+ /**
53
+ * StreamEvent envelope: discriminated union wrapping either a KernelEvent
54
+ * (source: 'kernel') or a ToolTraceEntry (source: 'evidence').
55
+ *
56
+ * Option B from WU-1918 spec notes.
57
+ */
58
+ export type StreamEvent =
59
+ | { source: 'kernel'; event: KernelEvent }
60
+ | { source: 'evidence'; trace: ToolTraceEntry };
61
+
62
+ export interface EventSubscriber {
63
+ subscribe(
64
+ filter: ReplayFilter,
65
+ callback: (event: KernelEvent) => void | Promise<void>,
66
+ ): Disposable;
67
+ }
68
+
69
+ export interface TraceSubscriber {
70
+ subscribe(taskId: string, callback: (trace: ToolTraceEntry) => void): Disposable;
71
+ }
72
+
73
+ export interface EventStreamRouterOptions {
74
+ traceSubscriber?: TraceSubscriber;
75
+ }
76
+
77
+ export interface EventStreamRouter {
78
+ handleRequest(
79
+ request: IncomingMessage,
80
+ response: ServerResponse<IncomingMessage>,
81
+ routeSegments: string[],
82
+ searchParams: URLSearchParams,
83
+ ): Promise<boolean>;
84
+ }
85
+
86
+ function writeJsonError(
87
+ response: ServerResponse<IncomingMessage>,
88
+ statusCode: number,
89
+ message: string,
90
+ ): void {
91
+ response.statusCode = statusCode;
92
+ response.setHeader(HEADER.CONTENT_TYPE, 'application/json; charset=utf-8');
93
+ response.end(
94
+ JSON.stringify({
95
+ [JSON_RESPONSE_KEY_ERROR]: {
96
+ [JSON_RESPONSE_KEY_MESSAGE]: message,
97
+ },
98
+ }),
99
+ );
100
+ }
101
+
102
+ function toKindFilter(searchParams: URLSearchParams): ReplayFilter['kind'] {
103
+ const values = searchParams
104
+ .getAll(SEARCH_PARAM.KIND)
105
+ .map((value) => value.trim())
106
+ .filter((value): value is KernelEvent['kind'] => {
107
+ return value.length > 0 && REPLAY_KIND_VALUES.has(value as KernelEvent['kind']);
108
+ });
109
+
110
+ if (values.length === 0) {
111
+ return undefined;
112
+ }
113
+ if (values.length === 1) {
114
+ return values[0];
115
+ }
116
+ return values;
117
+ }
118
+
119
+ function toReplayFilter(taskId: string, searchParams: URLSearchParams): ReplayFilter {
120
+ const sinceTimestamp = searchParams.get(SEARCH_PARAM.SINCE_TIMESTAMP) ?? undefined;
121
+ const untilTimestamp = searchParams.get(SEARCH_PARAM.UNTIL_TIMESTAMP) ?? undefined;
122
+ return {
123
+ taskId,
124
+ kind: toKindFilter(searchParams),
125
+ sinceTimestamp,
126
+ untilTimestamp,
127
+ };
128
+ }
129
+
130
+ function writeEventStreamHeaders(response: ServerResponse<IncomingMessage>): void {
131
+ response.statusCode = HTTP_STATUS.OK;
132
+ response.setHeader(HEADER.CONTENT_TYPE, HEADER_VALUE.EVENT_STREAM);
133
+ response.setHeader(HEADER.CACHE_CONTROL, HEADER_VALUE.CACHE_CONTROL);
134
+ response.setHeader(HEADER.CONNECTION, HEADER_VALUE.CONNECTION);
135
+ }
136
+
137
+ function writeStreamEvent(response: ServerResponse<IncomingMessage>, envelope: StreamEvent): void {
138
+ const payload = `${SSE_DATA_PREFIX}${JSON.stringify(envelope)}${SSE_DOUBLE_NEWLINE}`;
139
+ response.write(payload);
140
+ }
141
+
142
+ export function createEventStreamRouter(
143
+ eventSubscriber?: EventSubscriber,
144
+ options?: EventStreamRouterOptions,
145
+ ): EventStreamRouter {
146
+ return {
147
+ async handleRequest(
148
+ request: IncomingMessage,
149
+ response: ServerResponse<IncomingMessage>,
150
+ routeSegments: string[],
151
+ searchParams: URLSearchParams,
152
+ ): Promise<boolean> {
153
+ if (routeSegments.length !== 1) {
154
+ writeJsonError(response, HTTP_STATUS.NOT_FOUND, 'Route not found.');
155
+ return true;
156
+ }
157
+
158
+ if ((request.method ?? '') !== HTTP_METHOD.GET) {
159
+ writeJsonError(response, HTTP_STATUS.METHOD_NOT_ALLOWED, 'Unsupported method.');
160
+ return true;
161
+ }
162
+
163
+ if (!eventSubscriber) {
164
+ writeJsonError(response, HTTP_STATUS.NOT_IMPLEMENTED, 'Event streaming is unavailable.');
165
+ return true;
166
+ }
167
+
168
+ const taskId = routeSegments[0] ?? '';
169
+ const filter = toReplayFilter(taskId, searchParams);
170
+
171
+ writeEventStreamHeaders(response);
172
+
173
+ let isDisposed = false;
174
+
175
+ const eventSub = eventSubscriber.subscribe(filter, async (event) => {
176
+ if (isDisposed) {
177
+ return;
178
+ }
179
+ writeStreamEvent(response, { source: 'kernel', event });
180
+ });
181
+
182
+ let traceSub: Disposable | undefined;
183
+ if (options?.traceSubscriber) {
184
+ traceSub = options.traceSubscriber.subscribe(taskId, (trace) => {
185
+ if (isDisposed) {
186
+ return;
187
+ }
188
+ writeStreamEvent(response, { source: 'evidence', trace });
189
+ });
190
+ }
191
+
192
+ const heartbeatTimer = setInterval(() => {
193
+ if (isDisposed) {
194
+ return;
195
+ }
196
+ response.write(SSE_HEARTBEAT_COMMENT);
197
+ }, HEARTBEAT_INTERVAL_MS);
198
+
199
+ const dispose = (): void => {
200
+ if (isDisposed) {
201
+ return;
202
+ }
203
+ isDisposed = true;
204
+ clearInterval(heartbeatTimer);
205
+ eventSub.dispose();
206
+ traceSub?.dispose();
207
+ };
208
+
209
+ request.on('close', dispose);
210
+ response.on('close', dispose);
211
+ response.on('finish', dispose);
212
+
213
+ return true;
214
+ },
215
+ };
216
+ }
package/http/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export * from './server.js';
5
+ export * from './ag-ui-adapter.js';
6
+ export * from './run-agent.js';
7
+ export * from './tool-api.js';
8
+ export * from './tool-discovery.js';
9
+ export * from './control-plane-event-subscriber.js';
10
+ export * from './sidecar-entry.js';