@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.
- package/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
package/http/server.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import type {
|
|
7
|
+
Disposable,
|
|
8
|
+
ExecutionContext,
|
|
9
|
+
KernelEvent,
|
|
10
|
+
KernelRuntime,
|
|
11
|
+
ReplayFilter,
|
|
12
|
+
} from '@lumenflow/kernel';
|
|
13
|
+
import { createEventStreamRouter, type EventSubscriber } from './event-stream.js';
|
|
14
|
+
import { createRunAgentRouter } from './run-agent.js';
|
|
15
|
+
import { createTaskApiRouter } from './task-api.js';
|
|
16
|
+
import { createToolApiRouter, type ToolApiRouter } from './tool-api.js';
|
|
17
|
+
import {
|
|
18
|
+
createToolDiscoveryRouter,
|
|
19
|
+
type ToolDiscoveryEntryInput,
|
|
20
|
+
type ToolDiscoveryRouter,
|
|
21
|
+
} from './tool-discovery.js';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS,
|
|
24
|
+
type ControlPlaneSyncPortLike,
|
|
25
|
+
wrapEventSubscriberWithControlPlaneSync,
|
|
26
|
+
} from './control-plane-event-subscriber.js';
|
|
27
|
+
|
|
28
|
+
const URL_BASE = 'http://localhost';
|
|
29
|
+
const ROUTE_SEGMENT = {
|
|
30
|
+
TASKS: 'tasks',
|
|
31
|
+
TOOLS: 'tools',
|
|
32
|
+
EVENTS: 'events',
|
|
33
|
+
AG_UI: 'ag-ui',
|
|
34
|
+
} as const;
|
|
35
|
+
const AG_UI_RUN_PATH_SEGMENTS = ['ag-ui', 'v1', 'run'] as const;
|
|
36
|
+
const HTTP_METHOD_POST = 'POST';
|
|
37
|
+
const DEFAULT_SURFACES_VERSION = '0.0.0-unversioned';
|
|
38
|
+
const DEFAULT_WORKSPACE_ID = 'workspace-default';
|
|
39
|
+
const HTTP_STATUS_NOT_FOUND = 404;
|
|
40
|
+
const HEADER_CONTENT_TYPE = 'content-type';
|
|
41
|
+
const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
|
|
42
|
+
const RESPONSE_ERROR_KEY = 'error';
|
|
43
|
+
const RESPONSE_MESSAGE_KEY = 'message';
|
|
44
|
+
|
|
45
|
+
export interface QueuedToolDispatchRequest {
|
|
46
|
+
tool_name: string;
|
|
47
|
+
input?: unknown;
|
|
48
|
+
context: ExecutionContext;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface QueuedToolDispatchResult {
|
|
52
|
+
statusCode: number;
|
|
53
|
+
body: unknown;
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RuntimeEventStoreLike {
|
|
58
|
+
subscribe(
|
|
59
|
+
filter: ReplayFilter,
|
|
60
|
+
callback: (event: KernelEvent) => void | Promise<void>,
|
|
61
|
+
): Disposable;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RuntimeWithSubscribeEvents extends KernelRuntime {
|
|
65
|
+
subscribeEvents?: RuntimeEventStoreLike['subscribe'];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RuntimeWithPrivateEventStore extends KernelRuntime {
|
|
69
|
+
eventStore?: RuntimeEventStoreLike;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HttpSurfaceOptions {
|
|
73
|
+
eventSubscriber?: EventSubscriber;
|
|
74
|
+
controlPlaneSyncPort?: ControlPlaneSyncPortLike;
|
|
75
|
+
workspaceId?: string;
|
|
76
|
+
controlPlaneSyncIntervalMs?: number;
|
|
77
|
+
controlPlaneDiagnosticsLogger?: Pick<Console, 'warn'>;
|
|
78
|
+
allowlistedTools?: readonly string[];
|
|
79
|
+
toolCatalog?: readonly ToolDiscoveryEntryInput[];
|
|
80
|
+
surfacesVersion?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HttpSurface {
|
|
84
|
+
handleRequest(request: IncomingMessage, response: ServerResponse<IncomingMessage>): Promise<void>;
|
|
85
|
+
dispatchQueuedToolCommand?(input: QueuedToolDispatchRequest): Promise<QueuedToolDispatchResult>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseRoute(request: IncomingMessage): {
|
|
89
|
+
segments: string[];
|
|
90
|
+
searchParams: URLSearchParams;
|
|
91
|
+
} {
|
|
92
|
+
const url = new URL(request.url ?? '/', URL_BASE);
|
|
93
|
+
const segments = url.pathname.split('/').filter((segment) => segment.length > 0);
|
|
94
|
+
return {
|
|
95
|
+
segments,
|
|
96
|
+
searchParams: url.searchParams,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeNotFound(response: ServerResponse<IncomingMessage>): void {
|
|
101
|
+
response.statusCode = HTTP_STATUS_NOT_FOUND;
|
|
102
|
+
response.setHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON);
|
|
103
|
+
response.end(
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
[RESPONSE_ERROR_KEY]: {
|
|
106
|
+
[RESPONSE_MESSAGE_KEY]: 'Route not found.',
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function matchesRunAgentRoute(segments: string[]): boolean {
|
|
113
|
+
if (segments.length !== AG_UI_RUN_PATH_SEGMENTS.length) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return segments.every((segment, index) => segment === AG_UI_RUN_PATH_SEGMENTS[index]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveEventSubscriber(
|
|
120
|
+
runtime: KernelRuntime,
|
|
121
|
+
options: HttpSurfaceOptions,
|
|
122
|
+
): EventSubscriber | undefined {
|
|
123
|
+
let subscriber: EventSubscriber | undefined;
|
|
124
|
+
|
|
125
|
+
if (options.eventSubscriber) {
|
|
126
|
+
subscriber = options.eventSubscriber;
|
|
127
|
+
} else {
|
|
128
|
+
const runtimeWithSubscribeEvents = runtime as RuntimeWithSubscribeEvents;
|
|
129
|
+
if (typeof runtimeWithSubscribeEvents.subscribeEvents === 'function') {
|
|
130
|
+
subscriber = {
|
|
131
|
+
subscribe: runtimeWithSubscribeEvents.subscribeEvents.bind(runtimeWithSubscribeEvents),
|
|
132
|
+
};
|
|
133
|
+
} else {
|
|
134
|
+
const runtimeWithPrivateEventStore = runtime as RuntimeWithPrivateEventStore;
|
|
135
|
+
if (runtimeWithPrivateEventStore.eventStore) {
|
|
136
|
+
subscriber = runtimeWithPrivateEventStore.eventStore;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (subscriber && options.controlPlaneSyncPort && options.workspaceId) {
|
|
142
|
+
return wrapEventSubscriberWithControlPlaneSync(subscriber, {
|
|
143
|
+
controlPlaneSyncPort: options.controlPlaneSyncPort,
|
|
144
|
+
workspaceId: options.workspaceId,
|
|
145
|
+
pollIntervalMs: options.controlPlaneSyncIntervalMs ?? DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS,
|
|
146
|
+
logger: options.controlPlaneDiagnosticsLogger,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return subscriber;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class CapturedResponse {
|
|
154
|
+
public statusCode = 200;
|
|
155
|
+
public body = '';
|
|
156
|
+
private readonly headers = new Map<string, string>();
|
|
157
|
+
|
|
158
|
+
public setHeader(name: string, value: string | number | readonly string[]): this {
|
|
159
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public write(chunk: string | Buffer): boolean {
|
|
164
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public end(chunk?: string | Buffer): this {
|
|
169
|
+
if (chunk !== undefined) {
|
|
170
|
+
this.write(chunk);
|
|
171
|
+
}
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
public toResult(): QueuedToolDispatchResult {
|
|
176
|
+
try {
|
|
177
|
+
return {
|
|
178
|
+
statusCode: this.statusCode,
|
|
179
|
+
body: this.body.length > 0 ? JSON.parse(this.body) : {},
|
|
180
|
+
headers: Object.fromEntries(this.headers.entries()),
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return {
|
|
184
|
+
statusCode: this.statusCode,
|
|
185
|
+
body: this.body,
|
|
186
|
+
headers: Object.fromEntries(this.headers.entries()),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createQueuedToolDispatchRequest(input: QueuedToolDispatchRequest): IncomingMessage {
|
|
193
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
194
|
+
method: string;
|
|
195
|
+
url: string;
|
|
196
|
+
headers: Record<string, string>;
|
|
197
|
+
};
|
|
198
|
+
request.method = HTTP_METHOD_POST;
|
|
199
|
+
request.url = `/tools/${encodeURIComponent(input.tool_name)}`;
|
|
200
|
+
request.headers = {
|
|
201
|
+
[HEADER_CONTENT_TYPE]: CONTENT_TYPE_JSON,
|
|
202
|
+
};
|
|
203
|
+
(request as unknown as PassThrough).end(
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
input: input.input ?? {},
|
|
206
|
+
context: input.context,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
return request;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function dispatchQueuedToolCommandWithRouter(
|
|
213
|
+
toolApiRouter: ToolApiRouter | undefined,
|
|
214
|
+
input: QueuedToolDispatchRequest,
|
|
215
|
+
): Promise<QueuedToolDispatchResult> {
|
|
216
|
+
if (!toolApiRouter) {
|
|
217
|
+
return {
|
|
218
|
+
statusCode: HTTP_STATUS_NOT_FOUND,
|
|
219
|
+
body: {
|
|
220
|
+
[RESPONSE_ERROR_KEY]: {
|
|
221
|
+
[RESPONSE_MESSAGE_KEY]:
|
|
222
|
+
'Queued command dispatch is unavailable because the HTTP tool surface is disabled.',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
headers: {
|
|
226
|
+
[HEADER_CONTENT_TYPE]: CONTENT_TYPE_JSON,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const response = new CapturedResponse();
|
|
232
|
+
await toolApiRouter.handleRequest(
|
|
233
|
+
createQueuedToolDispatchRequest(input),
|
|
234
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
235
|
+
[input.tool_name],
|
|
236
|
+
);
|
|
237
|
+
return response.toResult();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function dispatchQueuedToolCommandThroughHttpSurface(
|
|
241
|
+
surface: HttpSurface,
|
|
242
|
+
input: QueuedToolDispatchRequest,
|
|
243
|
+
): Promise<QueuedToolDispatchResult> {
|
|
244
|
+
if (typeof surface.dispatchQueuedToolCommand === 'function') {
|
|
245
|
+
return surface.dispatchQueuedToolCommand(input);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const response = new CapturedResponse();
|
|
249
|
+
await surface.handleRequest(
|
|
250
|
+
createQueuedToolDispatchRequest(input),
|
|
251
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
252
|
+
);
|
|
253
|
+
return response.toResult();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createHttpSurface(
|
|
257
|
+
runtime: KernelRuntime,
|
|
258
|
+
options: HttpSurfaceOptions = {},
|
|
259
|
+
): HttpSurface {
|
|
260
|
+
const taskApiRouter = createTaskApiRouter(runtime);
|
|
261
|
+
const toolApiRouter = options.allowlistedTools
|
|
262
|
+
? createToolApiRouter(runtime, { allowlistedTools: options.allowlistedTools })
|
|
263
|
+
: undefined;
|
|
264
|
+
const toolDiscoveryRouter: ToolDiscoveryRouter | undefined = options.toolCatalog
|
|
265
|
+
? createToolDiscoveryRouter({
|
|
266
|
+
toolCatalog: options.toolCatalog,
|
|
267
|
+
surfacesVersion: options.surfacesVersion ?? DEFAULT_SURFACES_VERSION,
|
|
268
|
+
workspaceId: options.workspaceId ?? DEFAULT_WORKSPACE_ID,
|
|
269
|
+
})
|
|
270
|
+
: undefined;
|
|
271
|
+
const eventStreamRouter = createEventStreamRouter(resolveEventSubscriber(runtime, options));
|
|
272
|
+
const runAgentConfig = options.workspaceId ? { workspaceId: options.workspaceId } : undefined;
|
|
273
|
+
const runAgentRouter = createRunAgentRouter(
|
|
274
|
+
runtime,
|
|
275
|
+
resolveEventSubscriber(runtime, options),
|
|
276
|
+
runAgentConfig,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
async dispatchQueuedToolCommand(
|
|
281
|
+
input: QueuedToolDispatchRequest,
|
|
282
|
+
): Promise<QueuedToolDispatchResult> {
|
|
283
|
+
return dispatchQueuedToolCommandWithRouter(toolApiRouter, input);
|
|
284
|
+
},
|
|
285
|
+
async handleRequest(
|
|
286
|
+
request: IncomingMessage,
|
|
287
|
+
response: ServerResponse<IncomingMessage>,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const route = parseRoute(request);
|
|
290
|
+
const rootSegment = route.segments[0] ?? '';
|
|
291
|
+
const nestedSegments = route.segments.slice(1);
|
|
292
|
+
|
|
293
|
+
if (rootSegment === ROUTE_SEGMENT.TASKS) {
|
|
294
|
+
await taskApiRouter.handleRequest(request, response, nestedSegments);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (rootSegment === ROUTE_SEGMENT.TOOLS) {
|
|
299
|
+
// Root-level /tools (no nested segments): discovery owns the route
|
|
300
|
+
// (GET + 405 for other methods) whenever a toolCatalog is registered.
|
|
301
|
+
if (toolDiscoveryRouter && nestedSegments.length === 0) {
|
|
302
|
+
await toolDiscoveryRouter.handleRequest(request, response, nestedSegments);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (toolApiRouter) {
|
|
306
|
+
await toolApiRouter.handleRequest(request, response, nestedSegments);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (rootSegment === ROUTE_SEGMENT.EVENTS) {
|
|
312
|
+
await eventStreamRouter.handleRequest(
|
|
313
|
+
request,
|
|
314
|
+
response,
|
|
315
|
+
nestedSegments,
|
|
316
|
+
route.searchParams,
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (matchesRunAgentRoute(route.segments)) {
|
|
322
|
+
await runAgentRouter.handleRequest(request, response);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
writeNotFound(response);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WU-2642 (INIT-057 Phase 4): Sidecar entry for control-plane event sync.
|
|
6
|
+
*
|
|
7
|
+
* ADR-058 decided that between wu:claim and wu:done the control plane needs
|
|
8
|
+
* sub-minute visibility into durable state changes. `createHttpSurface` already
|
|
9
|
+
* wraps event emission with `wrapEventSubscriberWithControlPlaneSync`, but CLI
|
|
10
|
+
* sessions don't boot the HTTP surface. This module exposes a sidecar entry
|
|
11
|
+
* point that reuses the same sync-port contract **without** importing
|
|
12
|
+
* server.ts / createHttpSurface — making it cheap to spawn from wu:claim.
|
|
13
|
+
*
|
|
14
|
+
* @module sidecar-entry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { KernelEvent } from '@lumenflow/kernel';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS,
|
|
20
|
+
type ControlPlaneSyncPortLike,
|
|
21
|
+
} from './control-plane-event-subscriber.js';
|
|
22
|
+
|
|
23
|
+
const CONTROL_PLANE_POLICY_DECISION_DENY = 'deny';
|
|
24
|
+
const WORKSPACE_WARNING_EVENT_KIND = 'workspace_warning';
|
|
25
|
+
const KERNEL_EVENT_SCHEMA_VERSION = 1;
|
|
26
|
+
const CLOCK_ISO_OFFSET = 36;
|
|
27
|
+
const SYNC_SESSION_ID_PREFIX = 'sidecar-sync';
|
|
28
|
+
const ACTIONABLE_SYNC_DIAGNOSTIC_SUFFIX = 'Check control_plane.endpoint and auth token.';
|
|
29
|
+
|
|
30
|
+
const DIAGNOSTIC_REASON = {
|
|
31
|
+
POLICY_PULL_FAILED: 'policy pull failed',
|
|
32
|
+
HEARTBEAT_FAILED: 'heartbeat failed',
|
|
33
|
+
EVENT_PUSH_FAILED: 'event forwarding failed',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export interface StartControlPlaneEventSyncOptions {
|
|
37
|
+
controlPlaneSyncPort: ControlPlaneSyncPortLike;
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
/** Poll cadence. Defaults to {@link DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS} when 0/undefined. */
|
|
40
|
+
syncIntervalMs?: number;
|
|
41
|
+
logger?: Pick<Console, 'warn'>;
|
|
42
|
+
/** Override for deterministic tests. */
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ControlPlaneEventSyncHandle {
|
|
47
|
+
/** Enqueue a kernel event for the next sync cycle. */
|
|
48
|
+
enqueue(event: KernelEvent): void;
|
|
49
|
+
/** Stop the interval timer and release any resources. */
|
|
50
|
+
dispose(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SyncState {
|
|
54
|
+
allowForwarding: boolean;
|
|
55
|
+
inFlight: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createWorkspaceWarningEvent(message: string, now: () => Date): KernelEvent {
|
|
59
|
+
return {
|
|
60
|
+
schema_version: KERNEL_EVENT_SCHEMA_VERSION,
|
|
61
|
+
kind: WORKSPACE_WARNING_EVENT_KIND,
|
|
62
|
+
timestamp: now().toISOString(),
|
|
63
|
+
message,
|
|
64
|
+
} as KernelEvent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createSyncSessionId(now: () => Date): string {
|
|
68
|
+
return `${SYNC_SESSION_ID_PREFIX}:${now().toISOString().slice(0, CLOCK_ISO_OFFSET)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function emitDiagnostic(
|
|
72
|
+
pendingEvents: KernelEvent[],
|
|
73
|
+
logger: Pick<Console, 'warn'> | undefined,
|
|
74
|
+
workspaceId: string,
|
|
75
|
+
reason: string,
|
|
76
|
+
now: () => Date,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const message =
|
|
79
|
+
`Control plane sync ${reason} for workspace "${workspaceId}". ` +
|
|
80
|
+
ACTIONABLE_SYNC_DIAGNOSTIC_SUFFIX;
|
|
81
|
+
logger?.warn?.(message);
|
|
82
|
+
pendingEvents.push(createWorkspaceWarningEvent(message, now));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function runSyncCycle(
|
|
86
|
+
options: Required<
|
|
87
|
+
Pick<StartControlPlaneEventSyncOptions, 'controlPlaneSyncPort' | 'workspaceId'>
|
|
88
|
+
> &
|
|
89
|
+
Pick<StartControlPlaneEventSyncOptions, 'logger'>,
|
|
90
|
+
sessionId: string,
|
|
91
|
+
pendingEvents: KernelEvent[],
|
|
92
|
+
state: SyncState,
|
|
93
|
+
now: () => Date,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (state.inFlight) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
state.inFlight = true;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
try {
|
|
102
|
+
const pulledPolicies = await options.controlPlaneSyncPort.pullPolicies({
|
|
103
|
+
workspace_id: options.workspaceId,
|
|
104
|
+
});
|
|
105
|
+
state.allowForwarding =
|
|
106
|
+
pulledPolicies.default_decision !== CONTROL_PLANE_POLICY_DECISION_DENY;
|
|
107
|
+
} catch {
|
|
108
|
+
state.allowForwarding = false;
|
|
109
|
+
await emitDiagnostic(
|
|
110
|
+
pendingEvents,
|
|
111
|
+
options.logger,
|
|
112
|
+
options.workspaceId,
|
|
113
|
+
DIAGNOSTIC_REASON.POLICY_PULL_FAILED,
|
|
114
|
+
now,
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await options.controlPlaneSyncPort.heartbeat({
|
|
121
|
+
workspace_id: options.workspaceId,
|
|
122
|
+
session_id: sessionId,
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
await emitDiagnostic(
|
|
126
|
+
pendingEvents,
|
|
127
|
+
options.logger,
|
|
128
|
+
options.workspaceId,
|
|
129
|
+
DIAGNOSTIC_REASON.HEARTBEAT_FAILED,
|
|
130
|
+
now,
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!state.allowForwarding || pendingEvents.length === 0) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const batch = pendingEvents.splice(0, pendingEvents.length);
|
|
140
|
+
try {
|
|
141
|
+
await options.controlPlaneSyncPort.pushKernelEvents({
|
|
142
|
+
workspace_id: options.workspaceId,
|
|
143
|
+
events: batch,
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
pendingEvents.unshift(...batch);
|
|
147
|
+
state.allowForwarding = false;
|
|
148
|
+
await emitDiagnostic(
|
|
149
|
+
pendingEvents,
|
|
150
|
+
options.logger,
|
|
151
|
+
options.workspaceId,
|
|
152
|
+
DIAGNOSTIC_REASON.EVENT_PUSH_FAILED,
|
|
153
|
+
now,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
state.inFlight = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Start the control-plane event sync sidecar.
|
|
163
|
+
*
|
|
164
|
+
* Returns a handle whose `enqueue(event)` buffers kernel events that will be
|
|
165
|
+
* forwarded on the next sync cycle, and whose `dispose()` stops the interval
|
|
166
|
+
* timer. Call `dispose()` on wu:done / session-end to avoid leaking handles.
|
|
167
|
+
*
|
|
168
|
+
* Unlike `createHttpSurface`, this entry does not boot routers, task API, or
|
|
169
|
+
* the event-stream SSE pipeline. It only needs a
|
|
170
|
+
* {@link ControlPlaneSyncPortLike}.
|
|
171
|
+
*/
|
|
172
|
+
export function startControlPlaneEventSync(
|
|
173
|
+
options: StartControlPlaneEventSyncOptions,
|
|
174
|
+
): ControlPlaneEventSyncHandle {
|
|
175
|
+
const intervalMs =
|
|
176
|
+
options.syncIntervalMs && options.syncIntervalMs > 0
|
|
177
|
+
? options.syncIntervalMs
|
|
178
|
+
: DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS;
|
|
179
|
+
|
|
180
|
+
const now = options.now ?? (() => new Date());
|
|
181
|
+
const sessionId = createSyncSessionId(now);
|
|
182
|
+
const pendingEvents: KernelEvent[] = [];
|
|
183
|
+
const state: SyncState = {
|
|
184
|
+
allowForwarding: true,
|
|
185
|
+
inFlight: false,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const timer = setInterval(() => {
|
|
189
|
+
void runSyncCycle(
|
|
190
|
+
{
|
|
191
|
+
controlPlaneSyncPort: options.controlPlaneSyncPort,
|
|
192
|
+
workspaceId: options.workspaceId,
|
|
193
|
+
logger: options.logger,
|
|
194
|
+
},
|
|
195
|
+
sessionId,
|
|
196
|
+
pendingEvents,
|
|
197
|
+
state,
|
|
198
|
+
now,
|
|
199
|
+
);
|
|
200
|
+
}, intervalMs);
|
|
201
|
+
|
|
202
|
+
// Don't block Node.js event loop from exiting in short-lived unit tests or
|
|
203
|
+
// one-shot CLI invocations that never call dispose().
|
|
204
|
+
if (typeof timer.unref === 'function') {
|
|
205
|
+
timer.unref();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
enqueue(event: KernelEvent): void {
|
|
210
|
+
pendingEvents.push(event);
|
|
211
|
+
},
|
|
212
|
+
dispose(): void {
|
|
213
|
+
clearInterval(timer);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export { DEFAULT_CONTROL_PLANE_SYNC_INTERVAL_MS };
|