@memberjunction/server 5.40.2 → 5.42.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/dist/agentSessions/HostInstance.d.ts +19 -0
- package/dist/agentSessions/HostInstance.d.ts.map +1 -0
- package/dist/agentSessions/HostInstance.js +48 -0
- package/dist/agentSessions/HostInstance.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +97 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
- package/dist/agentSessions/SessionJanitor.js +222 -0
- package/dist/agentSessions/SessionJanitor.js.map +1 -0
- package/dist/agentSessions/SessionManager.d.ts +142 -0
- package/dist/agentSessions/SessionManager.d.ts.map +1 -0
- package/dist/agentSessions/SessionManager.js +308 -0
- package/dist/agentSessions/SessionManager.js.map +1 -0
- package/dist/agentSessions/index.d.ts +5 -0
- package/dist/agentSessions/index.d.ts.map +1 -0
- package/dist/agentSessions/index.js +31 -0
- package/dist/agentSessions/index.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +4 -2
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -2
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +41 -7
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +1296 -49
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +50343 -43421
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.js +1 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.js +9 -10
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -54
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +108 -0
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
- package/dist/integration/CustomColumnPromoter.js +508 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -0
- package/dist/logging/StartupLogger.d.ts +177 -0
- package/dist/logging/StartupLogger.d.ts.map +1 -0
- package/dist/logging/StartupLogger.js +354 -0
- package/dist/logging/StartupLogger.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +21 -2
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/AgentSessionResolver.js +152 -0
- package/dist/resolvers/AgentSessionResolver.js.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.js +95 -0
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +432 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js +1108 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +0 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +5 -3
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +6 -5
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +83 -74
- package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +181 -0
- package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
- package/src/__tests__/SessionJanitor.test.ts +234 -0
- package/src/__tests__/SessionManager.test.ts +465 -0
- package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
- package/src/__tests__/subscriptionRedaction.test.ts +5 -0
- package/src/agentSessions/HostInstance.ts +53 -0
- package/src/agentSessions/SessionJanitor.ts +267 -0
- package/src/agentSessions/SessionManager.ts +446 -0
- package/src/agentSessions/index.ts +32 -0
- package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
- package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
- package/src/auth/initializeProviders.ts +4 -2
- package/src/config.ts +13 -2
- package/src/context.ts +42 -7
- package/src/generated/generated.ts +33808 -29025
- package/src/generic/ResolverBase.ts +1 -1
- package/src/generic/RunViewResolver.ts +9 -9
- package/src/index.ts +128 -54
- package/src/integration/CustomColumnPromoter.ts +597 -0
- package/src/logging/StartupLogger.ts +441 -0
- package/src/logging/variablesLoggingMiddleware.ts +25 -5
- package/src/resolvers/AgentSessionResolver.ts +138 -0
- package/src/resolvers/EntityPermissionResolver.ts +73 -0
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
- package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +1122 -0
- package/src/resolvers/RunAIAgentResolver.ts +1 -9
- package/src/rest/setupRESTEndpoints.ts +5 -3
- package/src/services/ScheduledJobsService.ts +6 -5
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// type-graphql decorators on the resolver call `Reflect.getMetadata`, which only exists when this
|
|
2
|
+
// polyfill is loaded first. MUST precede any import that pulls in the resolver file.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { RemoteBrowserCapabilityNotSupportedError } from '@memberjunction/remote-browser-base';
|
|
7
|
+
|
|
8
|
+
// type-graphql's `@Field()`/`@ObjectType()`/`@Mutation()` decorators need `emitDecoratorMetadata`
|
|
9
|
+
// reflection (a build-time tsc feature) that vitest's esbuild transform does NOT apply, so importing the
|
|
10
|
+
// decorated resolver throws "Unable to infer GraphQL type". This unit test exercises the resolver's plain
|
|
11
|
+
// TS logic (idempotency / capability fallback / publish envelope), not its GraphQL schema — so we replace
|
|
12
|
+
// the decorators with no-ops. (The real schema is validated by the build + integration tests.)
|
|
13
|
+
vi.mock('type-graphql', () => {
|
|
14
|
+
const noopDecorator = () => () => undefined;
|
|
15
|
+
return {
|
|
16
|
+
Resolver: noopDecorator,
|
|
17
|
+
Mutation: noopDecorator,
|
|
18
|
+
Query: noopDecorator,
|
|
19
|
+
Subscription: noopDecorator,
|
|
20
|
+
ObjectType: noopDecorator,
|
|
21
|
+
InputType: noopDecorator,
|
|
22
|
+
Field: noopDecorator,
|
|
23
|
+
Arg: noopDecorator,
|
|
24
|
+
Args: noopDecorator,
|
|
25
|
+
Ctx: noopDecorator,
|
|
26
|
+
PubSub: noopDecorator,
|
|
27
|
+
Root: noopDecorator,
|
|
28
|
+
Float: class {},
|
|
29
|
+
Int: class {},
|
|
30
|
+
ID: class {},
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// --- Mock the live-session engine the resolver drives. A single mutable session object lets each
|
|
35
|
+
// test script what StartAudioStream/StopAudioStream do (succeed / throw a capability error). ---
|
|
36
|
+
interface FakeLiveSession {
|
|
37
|
+
StartAudioStream: ReturnType<typeof vi.fn>;
|
|
38
|
+
StopAudioStream: ReturnType<typeof vi.fn>;
|
|
39
|
+
}
|
|
40
|
+
const liveSession: FakeLiveSession = {
|
|
41
|
+
StartAudioStream: vi.fn(async () => undefined),
|
|
42
|
+
StopAudioStream: vi.fn(async () => undefined),
|
|
43
|
+
};
|
|
44
|
+
const startSessionMock = vi.fn(async () => liveSession);
|
|
45
|
+
const getSessionMock = vi.fn(() => liveSession as FakeLiveSession | undefined);
|
|
46
|
+
vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
47
|
+
RemoteBrowserEngine: {
|
|
48
|
+
Instance: {
|
|
49
|
+
StartSessionForAgentSession: (...args: unknown[]) => startSessionMock(...(args as [])),
|
|
50
|
+
GetSessionForAgentSession: (...args: unknown[]) => getSessionMock(...(args as [])),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Keep the AI imports (visual interpreter) inert — they aren't exercised by the audio path.
|
|
56
|
+
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
57
|
+
vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
|
|
58
|
+
// Spread the real module so transitively-loaded code (e.g. ArtifactToolManager → DataSnapshotToolLibrary,
|
|
59
|
+
// which extends BaseArtifactToolLibrary) still resolves with its REAL base classes; only AIPromptParams is
|
|
60
|
+
// overridden for the test. (Subsumes the narrower stub-two-exports approach — any other transitive export
|
|
61
|
+
// is supplied by the real module rather than coming back undefined.)
|
|
62
|
+
vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
|
|
63
|
+
...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
|
|
64
|
+
AIPromptParams: class {},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
import { RemoteBrowserActionResolver } from '../resolvers/RemoteBrowserActionResolver.js';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A test subclass that bypasses the ownership/provider plumbing (covered elsewhere) so the audio-stream
|
|
71
|
+
* logic — idempotency, capability fallback, the publish envelope — is exercised in isolation.
|
|
72
|
+
*/
|
|
73
|
+
class TestableResolver extends RemoteBrowserActionResolver {
|
|
74
|
+
// Stub the user/provider gate to a fixed authenticated user.
|
|
75
|
+
protected requireUserAndProvider() {
|
|
76
|
+
return { contextUser: { ID: 'user-1' }, provider: {} } as never;
|
|
77
|
+
}
|
|
78
|
+
// Stub session ownership + provider-name resolution.
|
|
79
|
+
protected async loadOwnedSession() {
|
|
80
|
+
return { ID: 'sess-1', AgentID: 'agent-1', UserID: 'user-1' } as never;
|
|
81
|
+
}
|
|
82
|
+
protected async resolveProviderName() {
|
|
83
|
+
return 'Self-Hosted Chrome';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A minimal pub/sub spy capturing every published envelope. */
|
|
88
|
+
function makePubSub(): { publish: ReturnType<typeof vi.fn>; published: Array<{ topic: string; payload: unknown }> } {
|
|
89
|
+
const published: Array<{ topic: string; payload: unknown }> = [];
|
|
90
|
+
return {
|
|
91
|
+
publish: vi.fn((topic: string, payload: unknown) => {
|
|
92
|
+
published.push({ topic, payload });
|
|
93
|
+
return Promise.resolve();
|
|
94
|
+
}),
|
|
95
|
+
published,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ctx = { userPayload: { sessionId: 'push-sess-1' }, providers: {} } as never;
|
|
100
|
+
|
|
101
|
+
describe('RemoteBrowserActionResolver — audio stream', () => {
|
|
102
|
+
let resolver: TestableResolver;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
resolver = new TestableResolver();
|
|
106
|
+
liveSession.StartAudioStream.mockReset().mockResolvedValue(undefined);
|
|
107
|
+
liveSession.StopAudioStream.mockReset().mockResolvedValue(undefined);
|
|
108
|
+
startSessionMock.mockReset().mockResolvedValue(liveSession);
|
|
109
|
+
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('starts the stream and reports Streaming: true', async () => {
|
|
113
|
+
const pubSub = makePubSub();
|
|
114
|
+
const result = await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
115
|
+
expect(result).toEqual({ Streaming: true });
|
|
116
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('is idempotent — a second start does not re-invoke StartAudioStream', async () => {
|
|
120
|
+
const pubSub = makePubSub();
|
|
121
|
+
await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
122
|
+
const second = await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
123
|
+
expect(second).toEqual({ Streaming: true });
|
|
124
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('reports Streaming: false when the backend has no audio capability (graceful fallback)', async () => {
|
|
128
|
+
liveSession.StartAudioStream.mockRejectedValueOnce(
|
|
129
|
+
new RemoteBrowserCapabilityNotSupportedError('AudioStreaming', 'Browserless'),
|
|
130
|
+
);
|
|
131
|
+
const pubSub = makePubSub();
|
|
132
|
+
const result = await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
133
|
+
expect(result).toEqual({ Streaming: false });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('publishes each chunk as a RemoteBrowserAudioChunk envelope scoped to the push session', async () => {
|
|
137
|
+
const pubSub = makePubSub();
|
|
138
|
+
// Capture the onChunk callback the resolver hands to StartAudioStream, then drive a chunk through it.
|
|
139
|
+
let onChunk: ((c: unknown) => void) | null = null;
|
|
140
|
+
liveSession.StartAudioStream.mockImplementationOnce(async (cb: (c: unknown) => void) => {
|
|
141
|
+
onChunk = cb;
|
|
142
|
+
});
|
|
143
|
+
await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
144
|
+
|
|
145
|
+
onChunk?.({ DataBase64: 'QUJD', Codec: 'webm-opus', SampleRate: 48000, Channels: 2, SequenceNumber: 7 });
|
|
146
|
+
|
|
147
|
+
expect(pubSub.published).toHaveLength(1);
|
|
148
|
+
const envelope = pubSub.published[0] as { topic: string; payload: { message: string; sessionId: string } };
|
|
149
|
+
expect(envelope.topic).toBe('PUSH_STATUS_UPDATES');
|
|
150
|
+
expect(envelope.payload.sessionId).toBe('push-sess-1');
|
|
151
|
+
expect(JSON.parse(envelope.payload.message)).toEqual({
|
|
152
|
+
resolver: 'RemoteBrowserActionResolver',
|
|
153
|
+
type: 'RemoteBrowserAudioChunk',
|
|
154
|
+
agentSessionID: 'sess-1',
|
|
155
|
+
dataBase64: 'QUJD',
|
|
156
|
+
codec: 'webm-opus',
|
|
157
|
+
sampleRate: 48000,
|
|
158
|
+
channels: 2,
|
|
159
|
+
seq: 7,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('stop clears the started set (so a later start re-invokes) and best-effort stops the session', async () => {
|
|
164
|
+
const pubSub = makePubSub();
|
|
165
|
+
await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
166
|
+
const stopped = await resolver.StopRemoteBrowserAudioStream('sess-1', ctx);
|
|
167
|
+
expect(stopped).toBe(true);
|
|
168
|
+
expect(liveSession.StopAudioStream).toHaveBeenCalledTimes(1);
|
|
169
|
+
|
|
170
|
+
// After stop, a new start re-invokes StartAudioStream (idempotency set was cleared).
|
|
171
|
+
await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
172
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('stop is best-effort — a StopAudioStream rejection still resolves true', async () => {
|
|
176
|
+
const pubSub = makePubSub();
|
|
177
|
+
await resolver.StartRemoteBrowserAudioStream('sess-1', ctx, pubSub as never);
|
|
178
|
+
liveSession.StopAudioStream.mockRejectedValueOnce(new Error('no audio backend'));
|
|
179
|
+
await expect(resolver.StopRemoteBrowserAudioStream('sess-1', ctx)).resolves.toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { RunComputerUseParams, type StepRecord } from '@memberjunction/computer-use';
|
|
4
|
+
import { MJComputerUseEngine, MJRunComputerUseParams } from '@memberjunction/computer-use-engine';
|
|
5
|
+
import { CdpRemoteBrowserSession, type ComputerUseGoalRun } from '@memberjunction/remote-browser-cdp';
|
|
6
|
+
import type { MJAIAgentRunStepEntity } from '@memberjunction/core-entities';
|
|
7
|
+
import {
|
|
8
|
+
buildMJGoalParams,
|
|
9
|
+
BindRemoteBrowserGoalEngine,
|
|
10
|
+
MJProgressComputerUseEngine,
|
|
11
|
+
extractCoAgentRunID,
|
|
12
|
+
finalizeBrowserGoalStep,
|
|
13
|
+
} from '../agentSessions/remoteBrowserGoalEngine.js';
|
|
14
|
+
|
|
15
|
+
const USER = { ID: 'u-1', Email: 'amith@bluecypress.io' } as unknown as UserInfo;
|
|
16
|
+
|
|
17
|
+
/** Builds a base RunComputerUseParams the CDP session would hand to the goal engine. */
|
|
18
|
+
function baseParams(): RunComputerUseParams {
|
|
19
|
+
return Object.assign(new RunComputerUseParams(), { Goal: 'log in', MaxSteps: 7, StartUrl: 'https://app.test/' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildMJGoalParams', () => {
|
|
23
|
+
it('produces MJ-aware params that carry over the base goal/step/url fields', () => {
|
|
24
|
+
const mj = buildMJGoalParams(baseParams(), USER);
|
|
25
|
+
expect(mj).toBeInstanceOf(MJRunComputerUseParams);
|
|
26
|
+
expect(mj.Goal).toBe('log in');
|
|
27
|
+
expect(mj.MaxSteps).toBe(7);
|
|
28
|
+
expect(mj.StartUrl).toBe('https://app.test/');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('injects the acting ContextUser so MJ prompts run as that user', () => {
|
|
32
|
+
expect(buildMJGoalParams(baseParams(), USER).ContextUser).toBe(USER);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('leaves ContextUser undefined when none is supplied', () => {
|
|
36
|
+
expect(buildMJGoalParams(baseParams()).ContextUser).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('threads the parent run + step ids for observability nesting', () => {
|
|
40
|
+
const mj = buildMJGoalParams(baseParams(), USER, 'coagent-run-1', 'goal-step-1');
|
|
41
|
+
expect(mj.AgentRunId).toBe('coagent-run-1');
|
|
42
|
+
expect(mj.AgentRunStepID).toBe('goal-step-1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('leaves the run/step ids undefined when not supplied', () => {
|
|
46
|
+
const mj = buildMJGoalParams(baseParams(), USER);
|
|
47
|
+
expect(mj.AgentRunId).toBeUndefined();
|
|
48
|
+
expect(mj.AgentRunStepID).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('extractCoAgentRunID', () => {
|
|
53
|
+
it('pulls coAgentRunID out of a session Config_ blob', () => {
|
|
54
|
+
expect(extractCoAgentRunID(JSON.stringify({ coAgentRunID: 'run-9', promptRunID: 'p' }))).toBe('run-9');
|
|
55
|
+
});
|
|
56
|
+
it('returns undefined for a missing key, null/empty config, or non-string value', () => {
|
|
57
|
+
expect(extractCoAgentRunID(JSON.stringify({ promptRunID: 'p' }))).toBeUndefined();
|
|
58
|
+
expect(extractCoAgentRunID(null)).toBeUndefined();
|
|
59
|
+
expect(extractCoAgentRunID(undefined)).toBeUndefined();
|
|
60
|
+
expect(extractCoAgentRunID('')).toBeUndefined();
|
|
61
|
+
expect(extractCoAgentRunID(JSON.stringify({ coAgentRunID: 123 }))).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it('returns undefined for malformed JSON (best-effort)', () => {
|
|
64
|
+
expect(extractCoAgentRunID('{not json')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('finalizeBrowserGoalStep', () => {
|
|
69
|
+
function fakeStep() {
|
|
70
|
+
return {
|
|
71
|
+
StartedAt: new Date(),
|
|
72
|
+
Save: vi.fn(async () => true),
|
|
73
|
+
LatestResult: { CompleteMessage: '' },
|
|
74
|
+
} as unknown as MJAIAgentRunStepEntity & { Save: ReturnType<typeof vi.fn> };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('finalizes the parent step Completed from a successful goal result + saves', async () => {
|
|
78
|
+
const step = fakeStep();
|
|
79
|
+
await finalizeBrowserGoalStep(step, { Success: true, Strategy: 'ComputerUse', Status: 'Completed', StepCount: 4, CurrentUrl: 'https://done/' });
|
|
80
|
+
expect(step.Status).toBe('Completed');
|
|
81
|
+
expect(step.Success).toBe(true);
|
|
82
|
+
expect((step as unknown as { Save: ReturnType<typeof vi.fn> }).Save).toHaveBeenCalledOnce();
|
|
83
|
+
const out = JSON.parse(step.OutputData as string);
|
|
84
|
+
expect(out.strategy).toBe('ComputerUse');
|
|
85
|
+
expect(out.stepCount).toBe(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('finalizes Failed and carries the detail as the error message', async () => {
|
|
89
|
+
const step = fakeStep();
|
|
90
|
+
await finalizeBrowserGoalStep(step, { Success: false, Status: 'Error', Detail: 'login blocked' });
|
|
91
|
+
expect(step.Status).toBe('Failed');
|
|
92
|
+
expect(step.ErrorMessage).toBe('login blocked');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is a no-op when the step is null (no co-agent run to nest under)', async () => {
|
|
96
|
+
await expect(finalizeBrowserGoalStep(null, { Success: true })).resolves.toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('MJProgressComputerUseEngine', () => {
|
|
101
|
+
it('is an MJ computer-use engine that satisfies the goal-run seam', () => {
|
|
102
|
+
const engine = new MJProgressComputerUseEngine();
|
|
103
|
+
expect(engine).toBeInstanceOf(MJComputerUseEngine);
|
|
104
|
+
// Seam surface the CDP session drives:
|
|
105
|
+
expect(typeof engine.SetBrowserAdapter).toBe('function');
|
|
106
|
+
expect(typeof engine.Stop).toBe('function');
|
|
107
|
+
expect(typeof engine.Run).toBe('function');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('forwards a model-safe progress note on each completed step', () => {
|
|
111
|
+
const engine = new MJProgressComputerUseEngine();
|
|
112
|
+
const notes: { Step: number; Message: string; Url?: string }[] = [];
|
|
113
|
+
engine.OnProgress = (p) => notes.push(p);
|
|
114
|
+
|
|
115
|
+
// onStepComplete is protected; the base MJ media-persistence path is a no-op here (no prompt-run id /
|
|
116
|
+
// context user set), so this exercises only the progress forwarding.
|
|
117
|
+
const step = { StepNumber: 2, ControllerReasoning: 'typing username', Url: 'https://app.test/login', Screenshot: '' } as StepRecord;
|
|
118
|
+
(engine as unknown as { onStepComplete(s: StepRecord, p: MJRunComputerUseParams): void }).onStepComplete(step, new MJRunComputerUseParams());
|
|
119
|
+
|
|
120
|
+
expect(notes).toEqual([{ Step: 2, Message: 'typing username', Url: 'https://app.test/login' }]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('BindRemoteBrowserGoalEngine', () => {
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('binds a factory that yields an MJ progress engine', () => {
|
|
130
|
+
const spy = vi.spyOn(CdpRemoteBrowserSession, 'SetGoalEngineFactory');
|
|
131
|
+
BindRemoteBrowserGoalEngine();
|
|
132
|
+
|
|
133
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
134
|
+
const factory = spy.mock.calls[0][0] as () => ComputerUseGoalRun;
|
|
135
|
+
expect(factory()).toBeInstanceOf(MJProgressComputerUseEngine);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('never throws (a binding hiccup must not abort server boot)', () => {
|
|
139
|
+
vi.spyOn(CdpRemoteBrowserSession, 'SetGoalEngineFactory').mockImplementation(() => {
|
|
140
|
+
throw new Error('boom');
|
|
141
|
+
});
|
|
142
|
+
expect(() => BindRemoteBrowserGoalEngine()).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock CanRun (unused by the janitor path, but SessionManager imports it).
|
|
4
|
+
vi.mock('@memberjunction/ai-engine-base', () => ({
|
|
5
|
+
AIAgentPermissionHelper: { HasPermission: vi.fn(async () => true) },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Capture the ExtraFilter passed to each sweep page and return controllable rows.
|
|
9
|
+
const runViewMock = vi.fn();
|
|
10
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
RunView: {
|
|
15
|
+
...actual.RunView,
|
|
16
|
+
FromMetadataProvider: () => ({ RunView: runViewMock }),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import { SessionJanitor } from '../agentSessions/SessionJanitor.js';
|
|
22
|
+
import { GetHostInstanceID, GetHostNamePrefix } from '../agentSessions/HostInstance.js';
|
|
23
|
+
import type { UserInfo, IMetadataProvider } from '@memberjunction/core';
|
|
24
|
+
|
|
25
|
+
interface FakeSession {
|
|
26
|
+
ID: string;
|
|
27
|
+
Status: 'Active' | 'Idle' | 'Closed';
|
|
28
|
+
Load: (id: string) => Promise<boolean>;
|
|
29
|
+
Save: () => Promise<boolean>;
|
|
30
|
+
LatestResult?: { CompleteMessage?: string };
|
|
31
|
+
ClosedAt?: Date | null;
|
|
32
|
+
CloseReason?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeUser(): UserInfo {
|
|
36
|
+
return { ID: 'system-user', Email: 'system@example.com' } as unknown as UserInfo;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a provider whose GetEntityObject (used by SessionManager.CloseSession to load a session by
|
|
41
|
+
* ID) returns a fresh entity backed by `store`, recording which IDs get closed. The channel sweep
|
|
42
|
+
* inside CloseSession uses runViewMock, which we default to an empty channel list.
|
|
43
|
+
*/
|
|
44
|
+
function makeProvider(store: Map<string, 'Active' | 'Idle' | 'Closed'>): {
|
|
45
|
+
provider: IMetadataProvider;
|
|
46
|
+
closedIds: string[];
|
|
47
|
+
closedReasons: Map<string, string | null>;
|
|
48
|
+
} {
|
|
49
|
+
const closedIds: string[] = [];
|
|
50
|
+
const closedReasons = new Map<string, string | null>();
|
|
51
|
+
const provider = {
|
|
52
|
+
GetEntityObject: vi.fn(async (entityName: string) => {
|
|
53
|
+
// Both the session-load and (within close) channel rows come through here in real code,
|
|
54
|
+
// but channels are returned via runViewMock; this factory only serves session loads.
|
|
55
|
+
const entity: FakeSession = {
|
|
56
|
+
ID: '',
|
|
57
|
+
Status: 'Active',
|
|
58
|
+
Load: vi.fn(async (id: string) => {
|
|
59
|
+
const status = store.get(id);
|
|
60
|
+
if (status == null) return false;
|
|
61
|
+
entity.ID = id;
|
|
62
|
+
entity.Status = status;
|
|
63
|
+
return true;
|
|
64
|
+
}),
|
|
65
|
+
Save: vi.fn(async () => {
|
|
66
|
+
if (entity.Status === 'Closed') {
|
|
67
|
+
store.set(entity.ID, 'Closed');
|
|
68
|
+
closedIds.push(entity.ID);
|
|
69
|
+
closedReasons.set(entity.ID, entity.CloseReason ?? null);
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}),
|
|
73
|
+
LatestResult: { CompleteMessage: '' },
|
|
74
|
+
};
|
|
75
|
+
return entity as unknown as Awaited<ReturnType<IMetadataProvider['GetEntityObject']>>;
|
|
76
|
+
}),
|
|
77
|
+
} as unknown as IMetadataProvider;
|
|
78
|
+
return { provider, closedIds, closedReasons };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Queue of result-sets for the *session* sweep pages (entity 'MJ: AI Agent Sessions'). Channel
|
|
83
|
+
* sweeps (entity 'MJ: AI Agent Session Channels', issued by CloseSession) always resolve empty so
|
|
84
|
+
* they never consume a queued session page. This keeps page assertions deterministic.
|
|
85
|
+
*/
|
|
86
|
+
let sessionPages: Array<{ Success: boolean; Results: unknown[]; ErrorMessage?: string }> = [];
|
|
87
|
+
|
|
88
|
+
/** Only the RunView calls that paged the session sweep (ignores the channel-sweep calls). */
|
|
89
|
+
function sessionSweepCalls(): Array<[{ EntityName: string; ExtraFilter: string; AfterKey?: unknown }]> {
|
|
90
|
+
return runViewMock.mock.calls.filter(
|
|
91
|
+
(c) => (c[0] as { EntityName: string }).EntityName === 'MJ: AI Agent Sessions',
|
|
92
|
+
) as Array<[{ EntityName: string; ExtraFilter: string; AfterKey?: unknown }]>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
runViewMock.mockReset();
|
|
97
|
+
sessionPages = [];
|
|
98
|
+
runViewMock.mockImplementation(async (params: { EntityName: string }) => {
|
|
99
|
+
if (params.EntityName === 'MJ: AI Agent Session Channels') {
|
|
100
|
+
return { Success: true, Results: [] };
|
|
101
|
+
}
|
|
102
|
+
return sessionPages.shift() ?? { Success: true, Results: [] };
|
|
103
|
+
});
|
|
104
|
+
// Reset the singleton's timers/state between tests.
|
|
105
|
+
SessionJanitor.Instance.Stop();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('SessionJanitor.RunStartupRecovery', () => {
|
|
109
|
+
it('closes prior-boot orphans of this host but not current-host or other-host sessions', async () => {
|
|
110
|
+
// The sweep SQL filters by host prefix + "<> current"; here we emulate the DB by having
|
|
111
|
+
// runViewMock return only the rows that filter WOULD match (prior-boot orphans). We assert
|
|
112
|
+
// (a) the filter is correct and (b) the returned orphans get closed.
|
|
113
|
+
const orphan = { ID: 'orphan-1', Status: 'Active' };
|
|
114
|
+
sessionPages = [{ Success: true, Results: [orphan] }];
|
|
115
|
+
|
|
116
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['orphan-1', 'Active']]);
|
|
117
|
+
const { provider, closedIds, closedReasons } = makeProvider(store);
|
|
118
|
+
|
|
119
|
+
const count = await SessionJanitor.Instance.RunStartupRecovery(provider, makeUser());
|
|
120
|
+
|
|
121
|
+
expect(count).toBe(1);
|
|
122
|
+
expect(closedIds).toContain('orphan-1');
|
|
123
|
+
// Orphan recovery stamps the janitor close cause.
|
|
124
|
+
expect(closedReasons.get('orphan-1')).toBe('Janitor');
|
|
125
|
+
|
|
126
|
+
// Assert the recovery filter targets this host's *other* boots, never the current instance.
|
|
127
|
+
const filter = sessionSweepCalls()[0][0].ExtraFilter as string;
|
|
128
|
+
expect(filter).toContain("Status IN ('Active','Idle')");
|
|
129
|
+
expect(filter).toContain(`HostInstanceID LIKE '${GetHostNamePrefix()}%'`);
|
|
130
|
+
expect(filter).toContain(`HostInstanceID <> '${GetHostInstanceID()}'`);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('SessionJanitor.RunStalenessSweep', () => {
|
|
135
|
+
it('closes only past-threshold sessions (filter is staleness-scoped)', async () => {
|
|
136
|
+
const stale = { ID: 'stale-1', Status: 'Idle' };
|
|
137
|
+
sessionPages = [{ Success: true, Results: [stale] }];
|
|
138
|
+
|
|
139
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['stale-1', 'Idle']]);
|
|
140
|
+
const { provider, closedIds, closedReasons } = makeProvider(store);
|
|
141
|
+
|
|
142
|
+
const count = await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
|
|
143
|
+
|
|
144
|
+
expect(count).toBe(1);
|
|
145
|
+
expect(closedIds).toContain('stale-1');
|
|
146
|
+
// Staleness sweep stamps the janitor close cause.
|
|
147
|
+
expect(closedReasons.get('stale-1')).toBe('Janitor');
|
|
148
|
+
|
|
149
|
+
const filter = sessionSweepCalls()[0][0].ExtraFilter as string;
|
|
150
|
+
expect(filter).toContain("Status IN ('Active','Idle')");
|
|
151
|
+
expect(filter).toMatch(/LastActiveAt < '.*'/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('is idempotent — re-closing already-Closed rows is a harmless no-op', async () => {
|
|
155
|
+
const alreadyClosed = { ID: 'done-1', Status: 'Closed' };
|
|
156
|
+
sessionPages = [{ Success: true, Results: [alreadyClosed] }];
|
|
157
|
+
|
|
158
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['done-1', 'Closed']]);
|
|
159
|
+
const { provider, closedIds } = makeProvider(store);
|
|
160
|
+
|
|
161
|
+
const count = await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
|
|
162
|
+
|
|
163
|
+
// CloseSession returns true (idempotent) but no new close-write happens.
|
|
164
|
+
expect(count).toBe(1);
|
|
165
|
+
expect(closedIds).not.toContain('done-1');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('uses keyset pagination — advances AfterKey across full pages and stops on a partial page', async () => {
|
|
169
|
+
// First page is full (200) → expect a second call with AfterKey set; second page partial → stop.
|
|
170
|
+
const fullPage = Array.from({ length: 200 }, (_, i) => ({ ID: `s-${i}`, Status: 'Active' }));
|
|
171
|
+
const tailPage = [{ ID: 's-tail', Status: 'Active' }];
|
|
172
|
+
sessionPages = [
|
|
173
|
+
{ Success: true, Results: fullPage },
|
|
174
|
+
{ Success: true, Results: tailPage },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>();
|
|
178
|
+
for (const r of [...fullPage, ...tailPage]) store.set(r.ID, 'Active');
|
|
179
|
+
const { provider } = makeProvider(store);
|
|
180
|
+
|
|
181
|
+
await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
|
|
182
|
+
|
|
183
|
+
const calls = sessionSweepCalls();
|
|
184
|
+
expect(calls).toHaveLength(2);
|
|
185
|
+
expect(calls[0][0].AfterKey).toBeUndefined();
|
|
186
|
+
expect(calls[1][0].AfterKey).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('stops cleanly on a load failure without throwing', async () => {
|
|
190
|
+
sessionPages = [{ Success: false, ErrorMessage: 'boom', Results: [] }];
|
|
191
|
+
const { provider, closedIds } = makeProvider(new Map());
|
|
192
|
+
|
|
193
|
+
const count = await SessionJanitor.Instance.RunStalenessSweep(provider, makeUser());
|
|
194
|
+
expect(count).toBe(0);
|
|
195
|
+
expect(closedIds.length).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('SessionJanitor shutdown drain', () => {
|
|
200
|
+
it('RunShutdownDrain closes only this exact host instance, stamping CloseReason = Shutdown', async () => {
|
|
201
|
+
const live = { ID: 'mine-1', Status: 'Active' };
|
|
202
|
+
sessionPages = [{ Success: true, Results: [live] }];
|
|
203
|
+
|
|
204
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['mine-1', 'Active']]);
|
|
205
|
+
const { provider, closedIds, closedReasons } = makeProvider(store);
|
|
206
|
+
|
|
207
|
+
const count = await SessionJanitor.Instance.RunShutdownDrain(provider, makeUser());
|
|
208
|
+
|
|
209
|
+
expect(count).toBe(1);
|
|
210
|
+
expect(closedIds).toContain('mine-1');
|
|
211
|
+
expect(closedReasons.get('mine-1')).toBe('Shutdown');
|
|
212
|
+
|
|
213
|
+
// The drain targets the CURRENT boot's exact instance id — not the host-prefix orphan filter.
|
|
214
|
+
const filter = sessionSweepCalls()[0][0].ExtraFilter as string;
|
|
215
|
+
expect(filter).toContain("Status IN ('Active','Idle')");
|
|
216
|
+
expect(filter).toContain(`HostInstanceID = '${GetHostInstanceID()}'`);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('Shutdown() drains this host\'s live sessions with the Shutdown reason after Start()', async () => {
|
|
220
|
+
// Start consumes one (empty) page for startup recovery; queue the drain page after it.
|
|
221
|
+
sessionPages = [
|
|
222
|
+
{ Success: true, Results: [] }, // startup recovery
|
|
223
|
+
{ Success: true, Results: [{ ID: 'mine-2', Status: 'Idle' }] }, // shutdown drain
|
|
224
|
+
];
|
|
225
|
+
const store = new Map<string, 'Active' | 'Idle' | 'Closed'>([['mine-2', 'Idle']]);
|
|
226
|
+
const { provider, closedIds, closedReasons } = makeProvider(store);
|
|
227
|
+
|
|
228
|
+
await SessionJanitor.Instance.Start(provider, makeUser(), 60_000);
|
|
229
|
+
await SessionJanitor.Instance.Shutdown();
|
|
230
|
+
|
|
231
|
+
expect(closedIds).toContain('mine-2');
|
|
232
|
+
expect(closedReasons.get('mine-2')).toBe('Shutdown');
|
|
233
|
+
});
|
|
234
|
+
});
|