@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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { PassThrough } from 'node:stream';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { issueControlPlaneIdentity } from '../../../control-plane-sdk/src/authenticate.js';
|
|
12
|
+
import { issueWorkspaceProfileIdentity, readWorkspaceSurfaceAuthConfig } from '../auth.js';
|
|
13
|
+
import { createToolApiRouter } from '../tool-api.js';
|
|
14
|
+
|
|
15
|
+
const HTTP_METHOD = {
|
|
16
|
+
POST: 'POST',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const HTTP_STATUS = {
|
|
20
|
+
OK: 200,
|
|
21
|
+
UNAUTHORIZED: 401,
|
|
22
|
+
FORBIDDEN: 403,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const TOOL_NAME = {
|
|
26
|
+
ORCHESTRATE_INITIATIVE: 'orchestrate:initiative',
|
|
27
|
+
WU_DELETE: 'wu:delete',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const IDENTITY_INPUT = {
|
|
31
|
+
workspace_id: 'workspace-test',
|
|
32
|
+
org_id: 'org-test',
|
|
33
|
+
agent_id: 'agent-test',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
class MockResponse extends EventEmitter {
|
|
37
|
+
statusCode = HTTP_STATUS.OK;
|
|
38
|
+
body = '';
|
|
39
|
+
private readonly headers = new Map<string, string>();
|
|
40
|
+
|
|
41
|
+
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
42
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
write(chunk: string | Buffer): boolean {
|
|
47
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
end(chunk?: string | Buffer): this {
|
|
52
|
+
if (chunk !== undefined) {
|
|
53
|
+
this.write(chunk);
|
|
54
|
+
}
|
|
55
|
+
this.emit('finish');
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createRequest(options: { body: unknown; token?: string }): IncomingMessage {
|
|
61
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
62
|
+
method: string;
|
|
63
|
+
url: string;
|
|
64
|
+
headers: IncomingHttpHeaders;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
request.method = HTTP_METHOD.POST;
|
|
68
|
+
request.url = '/';
|
|
69
|
+
request.headers = {
|
|
70
|
+
'content-type': 'application/json; charset=utf-8',
|
|
71
|
+
...(options.token
|
|
72
|
+
? {
|
|
73
|
+
authorization: `Bearer ${options.token}`,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
(request as unknown as PassThrough).end(JSON.stringify(options.body));
|
|
79
|
+
return request;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createContext() {
|
|
83
|
+
return {
|
|
84
|
+
run_id: 'run-tool-api',
|
|
85
|
+
task_id: 'WU-tool-api',
|
|
86
|
+
session_id: 'session-tool-api',
|
|
87
|
+
allowed_scopes: [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseBody(body: string): Record<string, unknown> {
|
|
92
|
+
return JSON.parse(body) as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('http tool scope enforcement', () => {
|
|
96
|
+
it('rejects POST /tools/:name with no Authorization header (WU-2779 security fix)', async () => {
|
|
97
|
+
const runtime = {
|
|
98
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
99
|
+
};
|
|
100
|
+
const router = createToolApiRouter(runtime as never, {
|
|
101
|
+
allowlistedTools: [TOOL_NAME.ORCHESTRATE_INITIATIVE, TOOL_NAME.WU_DELETE],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const request = createRequest({
|
|
105
|
+
// deliberately no token
|
|
106
|
+
body: { input: {}, context: createContext() },
|
|
107
|
+
});
|
|
108
|
+
const response = new MockResponse();
|
|
109
|
+
|
|
110
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
111
|
+
TOOL_NAME.WU_DELETE,
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
// Tool must NOT dispatch when no Authorization header is present.
|
|
115
|
+
expect(runtime.executeTool).not.toHaveBeenCalled();
|
|
116
|
+
// 401 UNAUTHORIZED — missing credentials, not a scope denial.
|
|
117
|
+
expect(response.statusCode).toBe(HTTP_STATUS.UNAUTHORIZED);
|
|
118
|
+
const body = parseBody(response.body);
|
|
119
|
+
expect(body).toEqual(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
error: 'missing_authorization',
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns 403 missing_scopes when a mobile token targets wu:delete', async () => {
|
|
127
|
+
const runtime = {
|
|
128
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
129
|
+
};
|
|
130
|
+
const router = createToolApiRouter(runtime as never, {
|
|
131
|
+
allowlistedTools: [TOOL_NAME.ORCHESTRATE_INITIATIVE, TOOL_NAME.WU_DELETE],
|
|
132
|
+
});
|
|
133
|
+
const mobileIdentity = issueControlPlaneIdentity(IDENTITY_INPUT, {
|
|
134
|
+
profile: 'mobile',
|
|
135
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
136
|
+
profiles: {
|
|
137
|
+
mobile: {
|
|
138
|
+
ttl_seconds: 60 * 60 * 24,
|
|
139
|
+
scopes: ['tool:orchestrate:initiative'],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const request = createRequest({
|
|
145
|
+
token: mobileIdentity.token,
|
|
146
|
+
body: { input: {}, context: createContext() },
|
|
147
|
+
});
|
|
148
|
+
const response = new MockResponse();
|
|
149
|
+
|
|
150
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
151
|
+
TOOL_NAME.WU_DELETE,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
expect(runtime.executeTool).not.toHaveBeenCalled();
|
|
155
|
+
expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
|
|
156
|
+
const body = parseBody(response.body);
|
|
157
|
+
expect(body).toEqual(
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
error: 'missing_scopes',
|
|
160
|
+
required: ['tool:wu:delete'],
|
|
161
|
+
granted: ['tool:orchestrate:initiative'],
|
|
162
|
+
requested: 'tool:wu:delete',
|
|
163
|
+
held: ['tool:orchestrate:initiative'],
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
expect(body.hint).toEqual(expect.stringContaining('tool:wu:delete'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('allows a privileged token to invoke orchestrate:initiative as well as wu:delete', async () => {
|
|
170
|
+
const runtime = {
|
|
171
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
172
|
+
};
|
|
173
|
+
const router = createToolApiRouter(runtime as never, {
|
|
174
|
+
allowlistedTools: [TOOL_NAME.ORCHESTRATE_INITIATIVE, TOOL_NAME.WU_DELETE],
|
|
175
|
+
});
|
|
176
|
+
const privilegedIdentity = issueControlPlaneIdentity(IDENTITY_INPUT, {
|
|
177
|
+
scopes: ['tool:*'],
|
|
178
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
179
|
+
ttl_seconds: 60,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const orchestrateResponse = new MockResponse();
|
|
183
|
+
await router.handleRequest(
|
|
184
|
+
createRequest({
|
|
185
|
+
token: privilegedIdentity.token,
|
|
186
|
+
body: { input: { initiativeId: 'INIT-057' }, context: createContext() },
|
|
187
|
+
}),
|
|
188
|
+
orchestrateResponse as unknown as ServerResponse<IncomingMessage>,
|
|
189
|
+
[TOOL_NAME.ORCHESTRATE_INITIATIVE],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const deleteResponse = new MockResponse();
|
|
193
|
+
await router.handleRequest(
|
|
194
|
+
createRequest({
|
|
195
|
+
token: privilegedIdentity.token,
|
|
196
|
+
body: { input: {}, context: createContext() },
|
|
197
|
+
}),
|
|
198
|
+
deleteResponse as unknown as ServerResponse<IncomingMessage>,
|
|
199
|
+
[TOOL_NAME.WU_DELETE],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(orchestrateResponse.statusCode).toBe(HTTP_STATUS.OK);
|
|
203
|
+
expect(deleteResponse.statusCode).toBe(HTTP_STATUS.OK);
|
|
204
|
+
expect(runtime.executeTool).toHaveBeenCalledTimes(2);
|
|
205
|
+
expect(runtime.executeTool).toHaveBeenNthCalledWith(
|
|
206
|
+
1,
|
|
207
|
+
TOOL_NAME.ORCHESTRATE_INITIATIVE,
|
|
208
|
+
{ initiativeId: 'INIT-057' },
|
|
209
|
+
createContext(),
|
|
210
|
+
);
|
|
211
|
+
expect(runtime.executeTool).toHaveBeenNthCalledWith(
|
|
212
|
+
2,
|
|
213
|
+
TOOL_NAME.WU_DELETE,
|
|
214
|
+
{},
|
|
215
|
+
createContext(),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('allows exact-scope mobile access to orchestrate:initiative', async () => {
|
|
220
|
+
const runtime = {
|
|
221
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
222
|
+
};
|
|
223
|
+
const router = createToolApiRouter(runtime as never, {
|
|
224
|
+
allowlistedTools: [TOOL_NAME.ORCHESTRATE_INITIATIVE, TOOL_NAME.WU_DELETE],
|
|
225
|
+
});
|
|
226
|
+
const mobileIdentity = issueControlPlaneIdentity(IDENTITY_INPUT, {
|
|
227
|
+
profile: 'mobile',
|
|
228
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
229
|
+
profiles: {
|
|
230
|
+
mobile: {
|
|
231
|
+
ttl_seconds: 60 * 60 * 24,
|
|
232
|
+
scopes: ['tool:orchestrate:initiative'],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const request = createRequest({
|
|
238
|
+
token: mobileIdentity.token,
|
|
239
|
+
body: { input: { initiativeId: 'INIT-057' }, context: createContext() },
|
|
240
|
+
});
|
|
241
|
+
const response = new MockResponse();
|
|
242
|
+
|
|
243
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
244
|
+
TOOL_NAME.ORCHESTRATE_INITIATIVE,
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
expect(runtime.executeTool).toHaveBeenCalledWith(
|
|
248
|
+
TOOL_NAME.ORCHESTRATE_INITIATIVE,
|
|
249
|
+
{ initiativeId: 'INIT-057' },
|
|
250
|
+
createContext(),
|
|
251
|
+
);
|
|
252
|
+
expect(response.statusCode).toBe(HTTP_STATUS.OK);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('allows privileged wildcard and pack wildcard tokens, and keeps legacy opaque tokens working', async () => {
|
|
256
|
+
const runtime = {
|
|
257
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
258
|
+
};
|
|
259
|
+
const router = createToolApiRouter(runtime as never, {
|
|
260
|
+
allowlistedTools: [TOOL_NAME.ORCHESTRATE_INITIATIVE, TOOL_NAME.WU_DELETE],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const privilegedIdentity = issueControlPlaneIdentity(IDENTITY_INPUT, {
|
|
264
|
+
scopes: ['tool:*'],
|
|
265
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
266
|
+
ttl_seconds: 60,
|
|
267
|
+
});
|
|
268
|
+
const packWildcardIdentity = issueControlPlaneIdentity(IDENTITY_INPUT, {
|
|
269
|
+
scopes: ['tool:wu:*'],
|
|
270
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
271
|
+
ttl_seconds: 60,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const privilegedResponse = new MockResponse();
|
|
275
|
+
await router.handleRequest(
|
|
276
|
+
createRequest({
|
|
277
|
+
token: privilegedIdentity.token,
|
|
278
|
+
body: { input: {}, context: createContext() },
|
|
279
|
+
}),
|
|
280
|
+
privilegedResponse as unknown as ServerResponse<IncomingMessage>,
|
|
281
|
+
[TOOL_NAME.WU_DELETE],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const packWildcardResponse = new MockResponse();
|
|
285
|
+
await router.handleRequest(
|
|
286
|
+
createRequest({
|
|
287
|
+
token: packWildcardIdentity.token,
|
|
288
|
+
body: { input: {}, context: createContext() },
|
|
289
|
+
}),
|
|
290
|
+
packWildcardResponse as unknown as ServerResponse<IncomingMessage>,
|
|
291
|
+
[TOOL_NAME.WU_DELETE],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const legacyResponse = new MockResponse();
|
|
295
|
+
await router.handleRequest(
|
|
296
|
+
createRequest({
|
|
297
|
+
token: 'legacy-opaque-bearer',
|
|
298
|
+
body: { input: {}, context: createContext() },
|
|
299
|
+
}),
|
|
300
|
+
legacyResponse as unknown as ServerResponse<IncomingMessage>,
|
|
301
|
+
[TOOL_NAME.WU_DELETE],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(runtime.executeTool).toHaveBeenCalledTimes(3);
|
|
305
|
+
expect(privilegedResponse.statusCode).toBe(HTTP_STATUS.OK);
|
|
306
|
+
expect(packWildcardResponse.statusCode).toBe(HTTP_STATUS.OK);
|
|
307
|
+
expect(legacyResponse.statusCode).toBe(HTTP_STATUS.OK);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('reads profile scopes from workspace.yaml overrides', () => {
|
|
311
|
+
const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'lumenflow-auth-config-'));
|
|
312
|
+
writeFileSync(
|
|
313
|
+
path.join(workspaceRoot, 'workspace.yaml'),
|
|
314
|
+
[
|
|
315
|
+
'software_delivery:',
|
|
316
|
+
' surfaces:',
|
|
317
|
+
' auth:',
|
|
318
|
+
' allow_legacy_unscoped_tokens: false',
|
|
319
|
+
' profiles:',
|
|
320
|
+
' mobile:',
|
|
321
|
+
' ttl_seconds: 900',
|
|
322
|
+
' scopes:',
|
|
323
|
+
' - tool:wu:status',
|
|
324
|
+
' privileged:',
|
|
325
|
+
' ttl_seconds: 1800',
|
|
326
|
+
' scopes:',
|
|
327
|
+
' - tool:wu:*',
|
|
328
|
+
'',
|
|
329
|
+
].join('\n'),
|
|
330
|
+
'utf8',
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const config = readWorkspaceSurfaceAuthConfig(workspaceRoot);
|
|
334
|
+
expect(config.allow_legacy_unscoped_tokens).toBe(false);
|
|
335
|
+
expect(config.profiles.mobile).toEqual({
|
|
336
|
+
ttl_seconds: 900,
|
|
337
|
+
scopes: ['tool:wu:status'],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const identity = issueWorkspaceProfileIdentity(IDENTITY_INPUT, {
|
|
341
|
+
profile: 'mobile',
|
|
342
|
+
now: '2026-04-17T12:00:00.000Z',
|
|
343
|
+
workspaceRoot,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(identity.scopes).toEqual(['tool:wu:status']);
|
|
347
|
+
expect(identity.expires_at).toBe('2026-04-17T12:15:00.000Z');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
* `startControlPlaneEventSync` must be importable without booting
|
|
8
|
+
* `createHttpSurface`, and must expose a clean `dispose()` that stops its
|
|
9
|
+
* interval timer (so `wu:done` / `session-end` can shut the sidecar down
|
|
10
|
+
* without leaking handles).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
import type { KernelEvent } from '@lumenflow/kernel';
|
|
15
|
+
import {
|
|
16
|
+
startControlPlaneEventSync,
|
|
17
|
+
type StartControlPlaneEventSyncOptions,
|
|
18
|
+
} from '../sidecar-entry.js';
|
|
19
|
+
import type { ControlPlaneSyncPortLike } from '../control-plane-event-subscriber.js';
|
|
20
|
+
|
|
21
|
+
const WORKSPACE_ID = 'workspace-sidecar-test';
|
|
22
|
+
const SYNC_INTERVAL_MS = 10;
|
|
23
|
+
const INTERVAL_STEP_MS = 12;
|
|
24
|
+
|
|
25
|
+
type PullPoliciesInput = Parameters<ControlPlaneSyncPortLike['pullPolicies']>[0];
|
|
26
|
+
type HeartbeatInput = Parameters<ControlPlaneSyncPortLike['heartbeat']>[0];
|
|
27
|
+
type PushEventsInput = Parameters<ControlPlaneSyncPortLike['pushKernelEvents']>[0];
|
|
28
|
+
|
|
29
|
+
function createFakeControlPlane(): {
|
|
30
|
+
port: ControlPlaneSyncPortLike;
|
|
31
|
+
calls: {
|
|
32
|
+
pulls: PullPoliciesInput[];
|
|
33
|
+
heartbeats: HeartbeatInput[];
|
|
34
|
+
pushes: PushEventsInput[];
|
|
35
|
+
};
|
|
36
|
+
} {
|
|
37
|
+
const calls = {
|
|
38
|
+
pulls: [] as PullPoliciesInput[],
|
|
39
|
+
heartbeats: [] as HeartbeatInput[],
|
|
40
|
+
pushes: [] as PushEventsInput[],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const port: ControlPlaneSyncPortLike = {
|
|
44
|
+
async pullPolicies(input) {
|
|
45
|
+
calls.pulls.push(input);
|
|
46
|
+
return { default_decision: 'allow', rules: [] };
|
|
47
|
+
},
|
|
48
|
+
async heartbeat(input) {
|
|
49
|
+
calls.heartbeats.push(input);
|
|
50
|
+
return { status: 'ok', server_time: new Date().toISOString() };
|
|
51
|
+
},
|
|
52
|
+
async pushKernelEvents(input) {
|
|
53
|
+
calls.pushes.push(input);
|
|
54
|
+
return { accepted: input.events.length };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return { port, calls };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function flushMicrotasks(): Promise<void> {
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
await Promise.resolve();
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('startControlPlaneEventSync (WU-2642)', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not require createHttpSurface to run', async () => {
|
|
77
|
+
// AC2: startControlPlaneEventSync is importable from @lumenflow/surfaces/http
|
|
78
|
+
// without requiring createHttpSurface. The import at the top of this file
|
|
79
|
+
// proves importability; this assertion proves it is also callable with a
|
|
80
|
+
// minimal options object (no KernelRuntime, no HTTP server).
|
|
81
|
+
const { port } = createFakeControlPlane();
|
|
82
|
+
const options: StartControlPlaneEventSyncOptions = {
|
|
83
|
+
controlPlaneSyncPort: port,
|
|
84
|
+
workspaceId: WORKSPACE_ID,
|
|
85
|
+
syncIntervalMs: SYNC_INTERVAL_MS,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handle = startControlPlaneEventSync(options);
|
|
89
|
+
expect(typeof handle.dispose).toBe('function');
|
|
90
|
+
handle.dispose();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('polls the control plane on the configured cadence and stops on dispose', async () => {
|
|
94
|
+
// AC1/AC4: the sidecar syncs on a configurable cadence (default 30s, but
|
|
95
|
+
// overridable via sync_interval). We fake-tick INTERVAL_STEP_MS twice and
|
|
96
|
+
// expect two heartbeats; then dispose, tick again, and expect no more.
|
|
97
|
+
const { port, calls } = createFakeControlPlane();
|
|
98
|
+
const handle = startControlPlaneEventSync({
|
|
99
|
+
controlPlaneSyncPort: port,
|
|
100
|
+
workspaceId: WORKSPACE_ID,
|
|
101
|
+
syncIntervalMs: SYNC_INTERVAL_MS,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_STEP_MS);
|
|
105
|
+
await flushMicrotasks();
|
|
106
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_STEP_MS);
|
|
107
|
+
await flushMicrotasks();
|
|
108
|
+
|
|
109
|
+
expect(calls.heartbeats.length).toBeGreaterThanOrEqual(2);
|
|
110
|
+
expect(calls.heartbeats[0].workspace_id).toBe(WORKSPACE_ID);
|
|
111
|
+
|
|
112
|
+
const heartbeatsBeforeDispose = calls.heartbeats.length;
|
|
113
|
+
handle.dispose();
|
|
114
|
+
|
|
115
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_STEP_MS * 3);
|
|
116
|
+
await flushMicrotasks();
|
|
117
|
+
|
|
118
|
+
// AC3: wu:done / session-end stops the sidecar cleanly (no more polls).
|
|
119
|
+
expect(calls.heartbeats.length).toBe(heartbeatsBeforeDispose);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('forwards queued events through pushKernelEvents on the next tick', async () => {
|
|
123
|
+
// AC5: Integration seam — an event enqueued to the sidecar must reach the
|
|
124
|
+
// fake control plane on the next sync tick. This is how wu:claim-era
|
|
125
|
+
// state changes propagate to cloud in sub-minute cadence.
|
|
126
|
+
const { port, calls } = createFakeControlPlane();
|
|
127
|
+
const handle = startControlPlaneEventSync({
|
|
128
|
+
controlPlaneSyncPort: port,
|
|
129
|
+
workspaceId: WORKSPACE_ID,
|
|
130
|
+
syncIntervalMs: SYNC_INTERVAL_MS,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const event: KernelEvent = {
|
|
134
|
+
schema_version: 1,
|
|
135
|
+
kind: 'workspace_warning',
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
message: 'test event from sidecar',
|
|
138
|
+
} as KernelEvent;
|
|
139
|
+
|
|
140
|
+
handle.enqueue(event);
|
|
141
|
+
|
|
142
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_STEP_MS);
|
|
143
|
+
await flushMicrotasks();
|
|
144
|
+
|
|
145
|
+
const pushedKinds = calls.pushes.flatMap((call) => call.events.map((e) => e.kind));
|
|
146
|
+
expect(pushedKinds).toContain('workspace_warning');
|
|
147
|
+
|
|
148
|
+
handle.dispose();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not import createHttpSurface (ADR-058 sidecar mode)', async () => {
|
|
152
|
+
// AC2 structural: the sidecar module must not transitively pull in the
|
|
153
|
+
// full HttpSurface stack (server.ts, run-agent, task-api). This keeps the
|
|
154
|
+
// sidecar cheap to spawn from wu:claim.
|
|
155
|
+
const sidecarModule = await import('../sidecar-entry.js');
|
|
156
|
+
expect(sidecarModule).not.toHaveProperty('createHttpSurface');
|
|
157
|
+
});
|
|
158
|
+
});
|