@memberjunction/server 5.36.0 → 5.38.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/README.md +12 -12
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +14 -2
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts +10 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +155 -13
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +97 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +553 -13
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -14
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +42 -28
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +5 -4
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +6 -18
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +6 -59
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/package.json +70 -70
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +269 -0
- package/src/__tests__/resolverBase.rls.test.ts +224 -0
- package/src/agents/skip-agent.ts +15 -2
- package/src/agents/skip-sdk.ts +163 -13
- package/src/generated/generated.ts +388 -14
- package/src/index.ts +62 -17
- package/src/resolvers/AdhocQueryResolver.ts +42 -28
- package/src/resolvers/QueryResolver.ts +7 -6
- package/src/resolvers/QuerySystemUserResolver.ts +11 -25
- package/src/resolvers/RunAIAgentResolver.ts +6 -66
package/src/index.ts
CHANGED
|
@@ -26,7 +26,9 @@ import sql from 'mssql';
|
|
|
26
26
|
import { WebSocketServer } from 'ws';
|
|
27
27
|
import buildApolloServer from './apolloServer/index.js';
|
|
28
28
|
import { configInfo, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
|
|
29
|
+
import { default as jwt } from 'jsonwebtoken';
|
|
29
30
|
import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
|
|
31
|
+
import { UserPayload } from './types.js';
|
|
30
32
|
import { requireSystemUserDirective, publicDirective } from './directives/index.js';
|
|
31
33
|
import createMSSQLConfig from './orm.js';
|
|
32
34
|
import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
|
|
@@ -667,28 +669,71 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
667
669
|
const httpServer = createServer(app);
|
|
668
670
|
|
|
669
671
|
const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
|
|
670
|
-
|
|
672
|
+
|
|
673
|
+
// Track per-connection expiry timers so we can clean them up on close
|
|
674
|
+
const expiryTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
|
|
675
|
+
|
|
676
|
+
const serverCleanup = useServer<Record<string, unknown>, { userPayload: UserPayload }>(
|
|
671
677
|
{
|
|
672
678
|
schema,
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
679
|
+
// RAII: validate the token once at connection time and cache the result.
|
|
680
|
+
// This prevents re-validating (and re-logging) on every subscription message.
|
|
681
|
+
onConnect: async (ctx) => {
|
|
682
|
+
try {
|
|
683
|
+
const token = String(ctx.connectionParams?.Authorization);
|
|
684
|
+
const userPayload = await getUserPayload(token, undefined, dataSources);
|
|
685
|
+
|
|
686
|
+
// Store validated payload on the connection for use in context()
|
|
687
|
+
ctx.extra.userPayload = userPayload;
|
|
688
|
+
|
|
689
|
+
// Schedule proactive socket close at token expiry so the client
|
|
690
|
+
// reconnects with a fresh token instead of spamming errors.
|
|
691
|
+
const decoded = jwt.decode(token.replace('Bearer ', ''));
|
|
692
|
+
if (decoded && typeof decoded !== 'string' && decoded.exp) {
|
|
693
|
+
const msUntilExpiry = decoded.exp * 1000 - Date.now();
|
|
694
|
+
if (msUntilExpiry > 0) {
|
|
695
|
+
const timer = setTimeout(() => {
|
|
696
|
+
console.log(`WebSocket token expiring — closing connection for ${userPayload.email}`);
|
|
697
|
+
// 4403 = Forbidden — retriable in graphql-ws, signals "get a new token"
|
|
698
|
+
// (4401 is reserved as fatal/"Unauthorized" in the graphql-ws protocol)
|
|
699
|
+
try {
|
|
700
|
+
ctx.extra.socket.close(4403, 'Token expired');
|
|
701
|
+
} catch {
|
|
702
|
+
// Socket may already be closed; benign
|
|
703
|
+
}
|
|
704
|
+
}, msUntilExpiry);
|
|
705
|
+
// Prevent the timer from keeping the process alive during shutdown
|
|
706
|
+
timer.unref();
|
|
707
|
+
expiryTimers.set(ctx, timer);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
683
710
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
console.
|
|
711
|
+
return true;
|
|
712
|
+
} catch (error: unknown) {
|
|
713
|
+
// Return false instead of throwing — graphql-ws sends 4403 Forbidden
|
|
714
|
+
// (retriable) rather than letting the throw produce 1011 (fatal).
|
|
715
|
+
const msg = error instanceof Error ? error.message : 'Authentication failed';
|
|
716
|
+
console.warn(`WebSocket connection rejected: ${msg}`);
|
|
717
|
+
return false;
|
|
690
718
|
}
|
|
691
719
|
},
|
|
720
|
+
// context() now just reads the already-validated payload — no I/O, no logging
|
|
721
|
+
context: async (ctx) => {
|
|
722
|
+
return { userPayload: ctx.extra.userPayload as UserPayload };
|
|
723
|
+
},
|
|
724
|
+
onClose: (ctx) => {
|
|
725
|
+
// Clear the expiry timer if the connection closes before the token expires
|
|
726
|
+
const timer = expiryTimers.get(ctx);
|
|
727
|
+
if (timer) {
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
expiryTimers.delete(ctx);
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
onError: (_ctx, _message, errors) => {
|
|
733
|
+
// Token expiry errors can't happen anymore (handled in onConnect + timer),
|
|
734
|
+
// so all errors here are genuine and worth logging.
|
|
735
|
+
console.error('WebSocket error:', errors);
|
|
736
|
+
},
|
|
692
737
|
},
|
|
693
738
|
webSocketServer
|
|
694
739
|
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
|
|
2
|
-
import { LogError } from '@memberjunction/core';
|
|
2
|
+
import { DatabasePlatform, LogError } from '@memberjunction/core';
|
|
3
3
|
import { SQLExpressionValidator } from '@memberjunction/global';
|
|
4
|
+
import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
4
5
|
import { AppContext } from '../types.js';
|
|
5
|
-
import { GetReadOnlyDataSource } from '../util.js';
|
|
6
|
+
import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
|
|
6
7
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
8
|
import { RunQueryResultType } from './QueryResolver.js';
|
|
8
9
|
import sql from 'mssql';
|
|
@@ -19,10 +20,10 @@ class AdhocQueryInput {
|
|
|
19
20
|
@Field(() => Int, { nullable: true, description: 'Query timeout in seconds. Defaults to 30.' })
|
|
20
21
|
TimeoutSeconds?: number;
|
|
21
22
|
|
|
22
|
-
@Field(() => Int, { nullable: true, description: 'Maximum number of rows to return
|
|
23
|
+
@Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
|
|
23
24
|
MaxRows?: number;
|
|
24
25
|
|
|
25
|
-
@Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination.
|
|
26
|
+
@Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
|
|
26
27
|
StartRow?: number;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -62,25 +63,45 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
62
63
|
return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// 3.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
// 3. Resolve platform from the read-only provider for the render pipeline.
|
|
67
|
+
let platform: DatabasePlatform = 'sqlserver';
|
|
68
|
+
try {
|
|
69
|
+
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
|
|
70
|
+
if (provider?.PlatformKey) platform = provider.PlatformKey;
|
|
71
|
+
} catch {
|
|
72
|
+
// Provider not configured — keep the default platform.
|
|
73
|
+
}
|
|
74
|
+
const contextUser = context.userPayload?.userRecord;
|
|
75
|
+
|
|
76
|
+
// 4. Route the SQL through RenderPipeline so composition tokens
|
|
77
|
+
// resolve, comments and templates are processed, and the row cap
|
|
78
|
+
// is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
|
|
70
79
|
const startRow = input.StartRow ?? 0;
|
|
71
80
|
const maxRows = input.MaxRows;
|
|
72
|
-
const
|
|
81
|
+
const usePaging =
|
|
73
82
|
maxRows != null &&
|
|
74
83
|
Number.isInteger(maxRows) &&
|
|
75
84
|
maxRows > 0 &&
|
|
76
85
|
Number.isInteger(startRow) &&
|
|
77
|
-
startRow
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
startRow > 0;
|
|
87
|
+
let executableSql: string;
|
|
88
|
+
try {
|
|
89
|
+
const rendered = RenderPipeline.Run(input.SQL, {
|
|
90
|
+
Platform: platform,
|
|
91
|
+
ContextUser: contextUser,
|
|
92
|
+
...(usePaging
|
|
93
|
+
? { Paging: { StartRow: startRow, MaxRows: maxRows! } }
|
|
94
|
+
: maxRows != null && maxRows > 0
|
|
95
|
+
? { MaxRows: maxRows }
|
|
96
|
+
: {}),
|
|
97
|
+
});
|
|
98
|
+
executableSql = rendered.FinalSQL;
|
|
99
|
+
} catch (renderErr) {
|
|
100
|
+
const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
|
|
101
|
+
return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
|
|
102
|
+
}
|
|
82
103
|
|
|
83
|
-
//
|
|
104
|
+
// 5. Execute with timeout
|
|
84
105
|
const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
|
|
85
106
|
const request = new sql.Request(readOnlyDS);
|
|
86
107
|
|
|
@@ -92,23 +113,16 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
92
113
|
]);
|
|
93
114
|
const executionTimeMs = Date.now() - startTime;
|
|
94
115
|
|
|
95
|
-
// 5. Apply in-memory pagination. With the wrap applied this is a no-op for
|
|
96
|
-
// first-page reads; for StartRow > 0 (or CTE-headed SQL where the wrap was
|
|
97
|
-
// skipped) it carves out the requested page.
|
|
98
|
-
const fullRecordset = result.recordset ?? [];
|
|
99
|
-
const totalRowCount = fullRecordset.length;
|
|
100
|
-
let paginated = fullRecordset;
|
|
101
|
-
if (startRow > 0) paginated = paginated.slice(startRow);
|
|
102
|
-
if (maxRows != null && maxRows > 0) paginated = paginated.slice(0, maxRows);
|
|
103
|
-
|
|
104
116
|
// 6. Return as RunQueryResultType
|
|
117
|
+
const recordset = result.recordset ?? [];
|
|
118
|
+
|
|
105
119
|
return {
|
|
106
120
|
QueryID: '',
|
|
107
121
|
QueryName: 'Ad-Hoc Query',
|
|
108
122
|
Success: true,
|
|
109
|
-
Results: JSON.stringify(
|
|
110
|
-
RowCount:
|
|
111
|
-
TotalRowCount:
|
|
123
|
+
Results: JSON.stringify(recordset),
|
|
124
|
+
RowCount: recordset.length,
|
|
125
|
+
TotalRowCount: recordset.length,
|
|
112
126
|
PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
|
|
113
127
|
PageSize: maxRows ?? undefined,
|
|
114
128
|
ExecutionTime: executionTimeMs,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Arg, Ctx, ObjectType, Query, Resolver, Field, Int, InputType } from 'type-graphql';
|
|
2
|
-
import { RunQuery,
|
|
2
|
+
import { RunQuery, IRunQueryProvider, IMetadataProvider, RunQueryParams, LogError, RunQueryWithCacheCheckParams, RunQueriesWithCacheCheckResponse, RunQueryWithCacheCheckResult } from '@memberjunction/core';
|
|
3
3
|
import { AppContext } from '../types.js';
|
|
4
4
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
5
5
|
import { GraphQLJSONObject } from 'graphql-type-json';
|
|
6
6
|
import { Metadata } from '@memberjunction/core';
|
|
7
|
+
import { MJQueryEntity, QueryEngine } from '@memberjunction/core-entities';
|
|
7
8
|
import { GetReadOnlyProvider } from '../util.js';
|
|
8
9
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
9
10
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
@@ -149,9 +150,9 @@ export class RunQueriesWithCacheCheckOutput {
|
|
|
149
150
|
|
|
150
151
|
@Resolver()
|
|
151
152
|
export class RunQueryResolver extends ResolverBase {
|
|
152
|
-
private async findQuery(md: IMetadataProvider, QueryID: string, QueryName?: string, CategoryID?: string, CategoryPath?: string, refreshMetadataIfNotFound: boolean = false): Promise<
|
|
153
|
-
|
|
154
|
-
const queries =
|
|
153
|
+
private async findQuery(md: IMetadataProvider, QueryID: string, QueryName?: string, CategoryID?: string, CategoryPath?: string, refreshMetadataIfNotFound: boolean = false): Promise<MJQueryEntity | null> {
|
|
154
|
+
const qe = QueryEngine.Instance;
|
|
155
|
+
const queries = qe.Queries.filter(q => {
|
|
155
156
|
if (QueryID) {
|
|
156
157
|
return q.ID.trim().toLowerCase() === QueryID.trim().toLowerCase();
|
|
157
158
|
} else if (QueryName) {
|
|
@@ -169,8 +170,8 @@ export class RunQueryResolver extends ResolverBase {
|
|
|
169
170
|
|
|
170
171
|
if (queries.length === 0) {
|
|
171
172
|
if (refreshMetadataIfNotFound) {
|
|
172
|
-
// If we didn't find the query, refresh
|
|
173
|
-
await
|
|
173
|
+
// If we didn't find the query, force-refresh QueryEngine and retry
|
|
174
|
+
await QueryEngine.Instance.Config(true);
|
|
174
175
|
return this.findQuery(md, QueryID, QueryName, CategoryID, CategoryPath, false); // change the refresh flag to false so we don't loop infinitely
|
|
175
176
|
}
|
|
176
177
|
else {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, Mutation, ObjectType, registerEnumType, Resolver, PubSub, PubSubEngine } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
|
-
import { LogError,
|
|
3
|
+
import { LogError, UserInfo, CompositeKey, DatabaseProviderBase, LogStatus } from '@memberjunction/core';
|
|
4
4
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
5
|
-
import { MJQueryCategoryEntity, MJQueryPermissionEntity } from '@memberjunction/core-entities';
|
|
5
|
+
import { MJQueryCategoryEntity, MJQueryPermissionEntity, MJQueryFieldEntity, MJQueryParameterEntity, MJQueryEntityEntity, QueryEngine } from '@memberjunction/core-entities';
|
|
6
6
|
import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEntity_, MJQueryPermission_ } from '../generated/generated.js';
|
|
7
7
|
import { GetReadWriteProvider } from '../util.js';
|
|
8
8
|
import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
|
|
@@ -301,12 +301,9 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
301
301
|
|
|
302
302
|
if (input.Permissions && input.Permissions.length > 0) {
|
|
303
303
|
await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
|
|
304
|
-
|
|
304
|
+
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
// Refresh metadata cache to include the newly created query
|
|
308
|
-
// This ensures subsequent operations can find the query without additional DB calls
|
|
309
|
-
await provider.Refresh();
|
|
310
307
|
|
|
311
308
|
return this.buildSuccessResult(record);
|
|
312
309
|
}
|
|
@@ -365,7 +362,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
365
362
|
};
|
|
366
363
|
}
|
|
367
364
|
|
|
368
|
-
private mapFields(fields:
|
|
365
|
+
private mapFields(fields: MJQueryFieldEntity[]): MJQueryField_[] {
|
|
369
366
|
return fields.map(f => ({
|
|
370
367
|
ID: f.ID,
|
|
371
368
|
QueryID: f.QueryID,
|
|
@@ -389,7 +386,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
389
386
|
}) as MJQueryField_);
|
|
390
387
|
}
|
|
391
388
|
|
|
392
|
-
private mapParameters(params:
|
|
389
|
+
private mapParameters(params: MJQueryParameterEntity[]): MJQueryParameter_[] {
|
|
393
390
|
return params.map(p => ({
|
|
394
391
|
ID: p.ID,
|
|
395
392
|
QueryID: p.QueryID,
|
|
@@ -408,7 +405,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
408
405
|
}) as MJQueryParameter_);
|
|
409
406
|
}
|
|
410
407
|
|
|
411
|
-
private mapEntities(entities:
|
|
408
|
+
private mapEntities(entities: MJQueryEntityEntity[]): MJQueryEntity_[] {
|
|
412
409
|
return entities.map(e => ({
|
|
413
410
|
ID: e.ID,
|
|
414
411
|
QueryID: e.QueryID,
|
|
@@ -422,7 +419,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
422
419
|
}) as MJQueryEntity_);
|
|
423
420
|
}
|
|
424
421
|
|
|
425
|
-
private mapPermissions(permissions:
|
|
422
|
+
private mapPermissions(permissions: MJQueryPermissionEntity[]): MJQueryPermission_[] {
|
|
426
423
|
return permissions.map(p => ({
|
|
427
424
|
ID: p.ID,
|
|
428
425
|
QueryID: p.QueryID,
|
|
@@ -528,25 +525,14 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
528
525
|
|
|
529
526
|
// Handle permissions update if provided
|
|
530
527
|
if (input.Permissions !== undefined) {
|
|
531
|
-
// Delete existing permissions
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
ExtraFilter: `QueryID='${queryID}'`,
|
|
536
|
-
ResultType: 'entity_object'
|
|
537
|
-
}, context.userPayload.userRecord);
|
|
538
|
-
|
|
539
|
-
if (existingPermissions.Success && existingPermissions.Results) {
|
|
540
|
-
for (const perm of existingPermissions.Results) {
|
|
541
|
-
await perm.Delete();
|
|
542
|
-
}
|
|
528
|
+
// Delete existing permissions (read from QueryEngine cache)
|
|
529
|
+
const existingPermissions = QueryEngine.Instance.GetQueryPermissions(queryID);
|
|
530
|
+
for (const perm of existingPermissions) {
|
|
531
|
+
await perm.Delete();
|
|
543
532
|
}
|
|
544
533
|
|
|
545
534
|
// Create new permissions
|
|
546
535
|
await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
|
|
547
|
-
|
|
548
|
-
// Refresh the metadata to get updated permissions
|
|
549
|
-
await queryEntity.RefreshRelatedMetadata(true);
|
|
550
536
|
}
|
|
551
537
|
|
|
552
538
|
return this.buildSuccessResult(queryEntity);
|
|
@@ -1298,13 +1298,13 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1298
1298
|
// Reverse to get chronological order (oldest first)
|
|
1299
1299
|
const details = detailsResult.Results.reverse();
|
|
1300
1300
|
|
|
1301
|
-
// Get all message IDs for batch loading
|
|
1301
|
+
// Get all message IDs for batch loading artifacts
|
|
1302
1302
|
const messageIds = details.map(d => d.ID);
|
|
1303
1303
|
|
|
1304
|
-
// Batch load
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
//
|
|
1304
|
+
// Batch load input artifacts for these messages. Since the backfill migration
|
|
1305
|
+
// (V202605271400__Backfill_Attachment_Artifacts) converted all legacy
|
|
1306
|
+
// ConversationDetailAttachment rows to artifact pairs, the artifact junction
|
|
1307
|
+
// is the single source of truth — no separate attachment query needed.
|
|
1308
1308
|
const inputArtifactsByDetailId = await this.loadInputArtifactsBatch(messageIds, contextUser, provider);
|
|
1309
1309
|
|
|
1310
1310
|
// Build ChatMessage array with attachments and input artifacts
|
|
@@ -1312,69 +1312,9 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1312
1312
|
|
|
1313
1313
|
for (const detail of details) {
|
|
1314
1314
|
const role = this.mapDetailRoleToMessageRole(detail.Role);
|
|
1315
|
-
const attachments = attachmentsByDetailId.get(detail.ID) || [];
|
|
1316
|
-
|
|
1317
|
-
// Get attachment data with content URLs (handles both inline and FileID storage)
|
|
1318
|
-
const attachmentDataPromises = attachments.map(att =>
|
|
1319
|
-
attachmentService.GetAttachmentData(att, contextUser, provider)
|
|
1320
|
-
);
|
|
1321
|
-
const attachmentDataResults = await Promise.all(attachmentDataPromises);
|
|
1322
|
-
|
|
1323
|
-
// Decide inline vs. tools per-attachment via the artifact-type registry
|
|
1324
|
-
// and the pure RouteArtifact() function. See plans/artifact-attachment-unification.md.
|
|
1325
|
-
//
|
|
1326
|
-
// Note on the modality check: this resolver doesn't know which model will
|
|
1327
|
-
// ultimately run, so RouteArtifact's modality predicate is passed as a no-op.
|
|
1328
|
-
// Model-specific modality enforcement is the driver layer's responsibility;
|
|
1329
|
-
// a follow-up PR can thread the active model through here to surface modality
|
|
1330
|
-
// mismatches at the hard-error path described in plan §4.
|
|
1331
1315
|
const validAttachments: AttachmentData[] = [];
|
|
1332
1316
|
|
|
1333
|
-
for
|
|
1334
|
-
if (!result) continue;
|
|
1335
|
-
// Storage-unified attachments link forward to their artifact version
|
|
1336
|
-
// via ArtifactVersionID; the artifact path handles delivery, so skip
|
|
1337
|
-
// the attachment row here to avoid double-processing.
|
|
1338
|
-
if (result.attachment.ArtifactVersionID) {
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
const mime = result.attachment.MimeType || '';
|
|
1342
|
-
const fileName = result.attachment.FileName ?? '';
|
|
1343
|
-
const ext = fileName.includes('.') ? fileName.split('.').pop() : undefined;
|
|
1344
|
-
const artifactType = ArtifactMetadataEngine.Instance.GetArtifactTypeByMimeType(mime, ext);
|
|
1345
|
-
|
|
1346
|
-
const decision = RouteArtifact({
|
|
1347
|
-
typeDefault: artifactType?.DefaultDeliveryMode ?? 'ToolsOnly',
|
|
1348
|
-
forceToolsOnly: false,
|
|
1349
|
-
mimeType: mime,
|
|
1350
|
-
sizeBytes: result.attachment.FileSizeBytes ?? 0,
|
|
1351
|
-
inlineSizeCap: INLINE_SIZE_CAP,
|
|
1352
|
-
modelSupportsModality: () => true,
|
|
1353
|
-
modelName: '<resolver>',
|
|
1354
|
-
artifactTypeName: artifactType?.Name ?? mime,
|
|
1355
|
-
});
|
|
1356
|
-
|
|
1357
|
-
if (decision.delivery !== 'inline') {
|
|
1358
|
-
// Tools or error: skip inline embedding. The agent reaches the
|
|
1359
|
-
// bytes via artifact tools. Driver layer handles modality enforcement.
|
|
1360
|
-
if (decision.delivery === 'tools' && decision.annotation) {
|
|
1361
|
-
LogStatus(`[RunAIAgentResolver] ${decision.annotation}`);
|
|
1362
|
-
}
|
|
1363
|
-
continue;
|
|
1364
|
-
}
|
|
1365
|
-
validAttachments.push({
|
|
1366
|
-
type: ConversationUtility.GetAttachmentTypeFromMime(result.attachment.MimeType),
|
|
1367
|
-
mimeType: result.attachment.MimeType,
|
|
1368
|
-
fileName: result.attachment.FileName ?? undefined,
|
|
1369
|
-
sizeBytes: result.attachment.FileSizeBytes ?? undefined,
|
|
1370
|
-
width: result.attachment.Width ?? undefined,
|
|
1371
|
-
height: result.attachment.Height ?? undefined,
|
|
1372
|
-
durationSeconds: result.attachment.DurationSeconds ?? undefined,
|
|
1373
|
-
content: result.contentUrl
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Get input artifacts for this message — same routing logic, plus ForceToolsOnly.
|
|
1317
|
+
// Get input artifacts for this message — routing via RouteArtifact.
|
|
1378
1318
|
const inputArtifacts = inputArtifactsByDetailId.get(detail.ID) || [];
|
|
1379
1319
|
for (const artifactVersion of inputArtifacts) {
|
|
1380
1320
|
const artifactMime = artifactVersion.MimeType || '';
|