@harborclient/team-hub 0.4.1 → 0.4.3
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/cli.js +860 -22
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -21,9 +21,22 @@ import { Command as Command2 } from "commander";
|
|
|
21
21
|
|
|
22
22
|
// src/config/serverConfig.ts
|
|
23
23
|
import { existsSync, readFileSync } from "fs";
|
|
24
|
-
import
|
|
24
|
+
import path2 from "path";
|
|
25
25
|
import { parse as parseYaml } from "yaml";
|
|
26
26
|
|
|
27
|
+
// src/config/docsConfig.ts
|
|
28
|
+
import path from "path";
|
|
29
|
+
var DEFAULT_DOCS_SEARCH_INDEX_PATH = "/app/data/docsSearchIndex.json";
|
|
30
|
+
function resolveDocsSearchIndexPath(searchIndexPath) {
|
|
31
|
+
return path.isAbsolute(searchIndexPath) ? searchIndexPath : path.resolve(process.cwd(), searchIndexPath);
|
|
32
|
+
}
|
|
33
|
+
function normalizeDocsConfig(section) {
|
|
34
|
+
const trimmed = section.searchIndexPath?.trim();
|
|
35
|
+
return {
|
|
36
|
+
searchIndexPath: trimmed && trimmed.length > 0 ? trimmed : DEFAULT_DOCS_SEARCH_INDEX_PATH
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// src/config/llmConfig.ts
|
|
28
41
|
function headerRowsFromSingleKeyObject(record) {
|
|
29
42
|
const rows = [];
|
|
@@ -176,6 +189,9 @@ var pluginsSectionSchema = z.object({
|
|
|
176
189
|
catalogs: z.array(z.string().trim().url()).optional(),
|
|
177
190
|
trusted: z.array(z.string().trim().url()).optional()
|
|
178
191
|
});
|
|
192
|
+
var docsSectionSchema = z.object({
|
|
193
|
+
searchIndexPath: z.string().trim().min(1).optional()
|
|
194
|
+
});
|
|
179
195
|
var logLevelSchema = z.enum(["debug", "info", "warn", "error"]);
|
|
180
196
|
var loggingSectionSchema = z.object({
|
|
181
197
|
level: logLevelSchema.optional(),
|
|
@@ -188,6 +204,7 @@ var serverConfigDocumentSchema = z.object({
|
|
|
188
204
|
redis: redisSectionSchema,
|
|
189
205
|
llm: llmSectionSchema.optional(),
|
|
190
206
|
plugins: pluginsSectionSchema.optional(),
|
|
207
|
+
docs: docsSectionSchema.optional(),
|
|
191
208
|
logging: loggingSectionSchema.optional()
|
|
192
209
|
});
|
|
193
210
|
|
|
@@ -205,7 +222,7 @@ var ConfigError = class extends Error {
|
|
|
205
222
|
}
|
|
206
223
|
};
|
|
207
224
|
function resolveConfigPath(configPath) {
|
|
208
|
-
return
|
|
225
|
+
return path2.isAbsolute(configPath) ? configPath : path2.resolve(process.cwd(), configPath);
|
|
209
226
|
}
|
|
210
227
|
function assertDocumentShape(document) {
|
|
211
228
|
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
|
@@ -288,6 +305,14 @@ function parseServerConfig(document) {
|
|
|
288
305
|
}
|
|
289
306
|
plugins = normalizePluginsConfig(parsedPluginsSection.data);
|
|
290
307
|
}
|
|
308
|
+
let docs = null;
|
|
309
|
+
if (root.docs !== void 0) {
|
|
310
|
+
const parsedDocsSection = docsSectionSchema.safeParse(root.docs);
|
|
311
|
+
if (!parsedDocsSection.success) {
|
|
312
|
+
throw new ConfigError(formatZodError(parsedDocsSection.error));
|
|
313
|
+
}
|
|
314
|
+
docs = normalizeDocsConfig(parsedDocsSection.data);
|
|
315
|
+
}
|
|
291
316
|
let logging = DEFAULT_LOGGING_CONFIG;
|
|
292
317
|
if (root.logging !== void 0) {
|
|
293
318
|
const parsedLoggingSection = loggingSectionSchema.safeParse(root.logging);
|
|
@@ -303,6 +328,7 @@ function parseServerConfig(document) {
|
|
|
303
328
|
redis: parsedRedisSection.data,
|
|
304
329
|
llm,
|
|
305
330
|
plugins,
|
|
331
|
+
docs,
|
|
306
332
|
logging
|
|
307
333
|
};
|
|
308
334
|
}
|
|
@@ -364,6 +390,7 @@ var REQUESTS_COLLECTION = "requests";
|
|
|
364
390
|
var AUDIT_LOG_COLLECTION = "auditLog";
|
|
365
391
|
var LLM_USAGE_COLLECTION = "llmUsage";
|
|
366
392
|
var LLM_USAGE_LOG_COLLECTION = "llmUsageLog";
|
|
393
|
+
var RUN_RESULTS_COLLECTION = "runResults";
|
|
367
394
|
var WRITE_BATCH_LIMIT = 500;
|
|
368
395
|
|
|
369
396
|
// src/db/systemUsers.ts
|
|
@@ -400,7 +427,7 @@ var firestoreConfigSchema = z2.object({
|
|
|
400
427
|
|
|
401
428
|
// src/db/firestore/utils.ts
|
|
402
429
|
function parseAuditEntityType(value) {
|
|
403
|
-
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request") {
|
|
430
|
+
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
404
431
|
return value;
|
|
405
432
|
}
|
|
406
433
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
@@ -544,6 +571,19 @@ function mapFirestoreRequest(id, data) {
|
|
|
544
571
|
updatedByUserId: data.updatedByUserId ?? null
|
|
545
572
|
};
|
|
546
573
|
}
|
|
574
|
+
function mapFirestoreRunResult(id, data) {
|
|
575
|
+
return {
|
|
576
|
+
id,
|
|
577
|
+
kind: data.kind,
|
|
578
|
+
label: data.label,
|
|
579
|
+
collectionName: data.collectionName,
|
|
580
|
+
requestName: data.requestName,
|
|
581
|
+
summary: data.summary,
|
|
582
|
+
payload: data.payload,
|
|
583
|
+
createdAt: data.createdAt,
|
|
584
|
+
createdByUserId: data.createdByUserId ?? null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
547
587
|
function mapFirestoreAuditLog(id, data) {
|
|
548
588
|
return {
|
|
549
589
|
id,
|
|
@@ -557,6 +597,53 @@ function mapFirestoreAuditLog(id, data) {
|
|
|
557
597
|
};
|
|
558
598
|
}
|
|
559
599
|
|
|
600
|
+
// src/db/runResultPayload.ts
|
|
601
|
+
function isRecord(value) {
|
|
602
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
603
|
+
}
|
|
604
|
+
function summarizeResultRows(results) {
|
|
605
|
+
let passed = 0;
|
|
606
|
+
let failed = 0;
|
|
607
|
+
let skipped = 0;
|
|
608
|
+
for (const row of results) {
|
|
609
|
+
if (!isRecord(row)) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const status = row.status;
|
|
613
|
+
if (status === "passed") {
|
|
614
|
+
passed += 1;
|
|
615
|
+
} else if (status === "failed") {
|
|
616
|
+
failed += 1;
|
|
617
|
+
} else if (status === "skipped") {
|
|
618
|
+
skipped += 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { passed, failed, skipped };
|
|
622
|
+
}
|
|
623
|
+
function buildDefaultRunResultLabel(metadata) {
|
|
624
|
+
const target = metadata.requestName ?? metadata.collectionName ?? "Run";
|
|
625
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
|
626
|
+
return `${target} \u2014 ${timestamp}`;
|
|
627
|
+
}
|
|
628
|
+
function parseRunResultPayload(payload) {
|
|
629
|
+
const kind = payload.harborclientExport;
|
|
630
|
+
if (kind !== "collection-run-results" && kind !== "request-run-results") {
|
|
631
|
+
throw new Error("Invalid run result payload: harborclientExport is required");
|
|
632
|
+
}
|
|
633
|
+
const results = payload.results;
|
|
634
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
635
|
+
throw new Error("Invalid run result payload: results are required");
|
|
636
|
+
}
|
|
637
|
+
const collection = isRecord(payload.collection) ? payload.collection : void 0;
|
|
638
|
+
const request = isRecord(payload.request) ? payload.request : void 0;
|
|
639
|
+
return {
|
|
640
|
+
kind,
|
|
641
|
+
collectionName: typeof collection?.name === "string" ? collection.name : null,
|
|
642
|
+
requestName: typeof request?.name === "string" ? request.name : null,
|
|
643
|
+
summary: summarizeResultRows(results)
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
560
647
|
// src/db/trimRequiredName.ts
|
|
561
648
|
function trimRequiredName(name, label) {
|
|
562
649
|
const trimmed = name.trim();
|
|
@@ -1822,6 +1909,78 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1822
1909
|
}
|
|
1823
1910
|
return usage;
|
|
1824
1911
|
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Lists run results saved by the given user, newest first.
|
|
1914
|
+
*
|
|
1915
|
+
* @param userId - Owning user identifier.
|
|
1916
|
+
*/
|
|
1917
|
+
async listRunResultsForUser(userId) {
|
|
1918
|
+
const snapshot = await this.requireClient().collection(RUN_RESULTS_COLLECTION).where("createdByUserId", "==", userId).orderBy("createdAt", "desc").get();
|
|
1919
|
+
return snapshot.docs.map(
|
|
1920
|
+
(doc) => mapFirestoreRunResult(doc.id, doc.data())
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Lists all run results for admin inspection, newest first.
|
|
1925
|
+
*/
|
|
1926
|
+
async listAllRunResults() {
|
|
1927
|
+
const snapshot = await this.requireClient().collection(RUN_RESULTS_COLLECTION).orderBy("createdAt", "desc").get();
|
|
1928
|
+
return snapshot.docs.map(
|
|
1929
|
+
(doc) => mapFirestoreRunResult(doc.id, doc.data())
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Creates a standalone run result snapshot.
|
|
1934
|
+
*
|
|
1935
|
+
* @param input - HarborClient export payload and optional label.
|
|
1936
|
+
* @param actingUserId - User performing the create action.
|
|
1937
|
+
*/
|
|
1938
|
+
async createRunResult(input, actingUserId) {
|
|
1939
|
+
const metadata = parseRunResultPayload(input.payload);
|
|
1940
|
+
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
1941
|
+
const id = randomUUID2();
|
|
1942
|
+
const now = /* @__PURE__ */ new Date();
|
|
1943
|
+
const data = {
|
|
1944
|
+
kind: metadata.kind,
|
|
1945
|
+
label,
|
|
1946
|
+
collectionName: metadata.collectionName,
|
|
1947
|
+
requestName: metadata.requestName,
|
|
1948
|
+
summary: metadata.summary,
|
|
1949
|
+
payload: input.payload,
|
|
1950
|
+
createdAt: now,
|
|
1951
|
+
createdByUserId: actingUserId
|
|
1952
|
+
};
|
|
1953
|
+
await this.requireClient().collection(RUN_RESULTS_COLLECTION).doc(id).set(data);
|
|
1954
|
+
await this.recordAuditEntry(actingUserId, "create", "run_result", id);
|
|
1955
|
+
return mapFirestoreRunResult(id, data);
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Finds a run result by stable identifier.
|
|
1959
|
+
*
|
|
1960
|
+
* @param id - Run result ID to look up.
|
|
1961
|
+
*/
|
|
1962
|
+
async findRunResultById(id) {
|
|
1963
|
+
const snapshot = await this.requireClient().collection(RUN_RESULTS_COLLECTION).doc(id).get();
|
|
1964
|
+
if (!snapshot.exists) {
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
return mapFirestoreRunResult(id, snapshot.data());
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Deletes a run result.
|
|
1971
|
+
*
|
|
1972
|
+
* @param id - Run result ID to delete.
|
|
1973
|
+
* @param actingUserId - User performing the delete action.
|
|
1974
|
+
*/
|
|
1975
|
+
async deleteRunResult(id, actingUserId) {
|
|
1976
|
+
const docRef = this.requireClient().collection(RUN_RESULTS_COLLECTION).doc(id);
|
|
1977
|
+
const snapshot = await docRef.get();
|
|
1978
|
+
if (!snapshot.exists) {
|
|
1979
|
+
throw new Error("Run result not found");
|
|
1980
|
+
}
|
|
1981
|
+
await this.recordAuditEntry(actingUserId, "delete", "run_result", id);
|
|
1982
|
+
await docRef.delete();
|
|
1983
|
+
}
|
|
1825
1984
|
/**
|
|
1826
1985
|
* Inserts a per-request LLM usage log entry.
|
|
1827
1986
|
*
|
|
@@ -1976,7 +2135,7 @@ function parseAuditAction(value) {
|
|
|
1976
2135
|
throw new Error(`Invalid audit action: ${value}`);
|
|
1977
2136
|
}
|
|
1978
2137
|
function parseAuditEntityType2(value) {
|
|
1979
|
-
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request") {
|
|
2138
|
+
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
1980
2139
|
return value;
|
|
1981
2140
|
}
|
|
1982
2141
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
@@ -2073,6 +2232,23 @@ function mapSnippetSqlRow(row) {
|
|
|
2073
2232
|
deletionLocked: Boolean(row.deletion_locked)
|
|
2074
2233
|
};
|
|
2075
2234
|
}
|
|
2235
|
+
function mapRunResultSqlRow(row) {
|
|
2236
|
+
return {
|
|
2237
|
+
id: row.id,
|
|
2238
|
+
kind: row.kind,
|
|
2239
|
+
label: row.label,
|
|
2240
|
+
collectionName: row.collection_name,
|
|
2241
|
+
requestName: row.request_name,
|
|
2242
|
+
summary: {
|
|
2243
|
+
passed: row.summary_passed,
|
|
2244
|
+
failed: row.summary_failed,
|
|
2245
|
+
skipped: row.summary_skipped
|
|
2246
|
+
},
|
|
2247
|
+
payload: parseJson(row.payload, {}),
|
|
2248
|
+
createdAt: row.created_at,
|
|
2249
|
+
createdByUserId: row.created_by_user_id ?? null
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2076
2252
|
function mapFolderSqlRow(row) {
|
|
2077
2253
|
return {
|
|
2078
2254
|
id: row.id,
|
|
@@ -2349,6 +2525,22 @@ WHERE role = 'user'
|
|
|
2349
2525
|
AND snippet_access = '[]'
|
|
2350
2526
|
AND collection_access LIKE '%"*"%';
|
|
2351
2527
|
`.trim();
|
|
2528
|
+
var RUN_RESULTS_MIGRATION_SQL = `
|
|
2529
|
+
CREATE TABLE IF NOT EXISTS run_results (
|
|
2530
|
+
id VARCHAR(36) PRIMARY KEY,
|
|
2531
|
+
kind VARCHAR(64) NOT NULL,
|
|
2532
|
+
label VARCHAR(512) NOT NULL,
|
|
2533
|
+
collection_name VARCHAR(512),
|
|
2534
|
+
request_name VARCHAR(512),
|
|
2535
|
+
summary_passed INT NOT NULL DEFAULT 0,
|
|
2536
|
+
summary_failed INT NOT NULL DEFAULT 0,
|
|
2537
|
+
summary_skipped INT NOT NULL DEFAULT 0,
|
|
2538
|
+
payload LONGTEXT NOT NULL,
|
|
2539
|
+
created_at DATETIME NOT NULL,
|
|
2540
|
+
created_by_user_id VARCHAR(36),
|
|
2541
|
+
INDEX run_results_created_idx (created_at)
|
|
2542
|
+
)
|
|
2543
|
+
`.trim();
|
|
2352
2544
|
var MYSQL_MIGRATIONS = [
|
|
2353
2545
|
USERS_MIGRATION_SQL,
|
|
2354
2546
|
API_TOKENS_MIGRATION_SQL,
|
|
@@ -2374,7 +2566,8 @@ var MYSQL_MIGRATIONS = [
|
|
|
2374
2566
|
COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL,
|
|
2375
2567
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL,
|
|
2376
2568
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL,
|
|
2377
|
-
USERS_SNIPPET_ACCESS_BACKFILL_SQL
|
|
2569
|
+
USERS_SNIPPET_ACCESS_BACKFILL_SQL,
|
|
2570
|
+
RUN_RESULTS_MIGRATION_SQL
|
|
2378
2571
|
];
|
|
2379
2572
|
|
|
2380
2573
|
// src/db/mysql/schemas.ts
|
|
@@ -2478,6 +2671,8 @@ var LLM_USAGE_SELECT_COLUMNS = `id, user_id, period, prompt_tokens, completion_t
|
|
|
2478
2671
|
var COLLECTION_SELECT = `SELECT ${COLLECTION_SELECT_COLUMNS} FROM collections`;
|
|
2479
2672
|
var ENVIRONMENT_SELECT = `SELECT ${ENVIRONMENT_SELECT_COLUMNS} FROM environments`;
|
|
2480
2673
|
var SNIPPET_SELECT = `SELECT ${SNIPPET_SELECT_COLUMNS} FROM snippets`;
|
|
2674
|
+
var RUN_RESULT_SELECT_COLUMNS = "id, kind, label, collection_name, request_name, summary_passed, summary_failed, summary_skipped, payload, created_at, created_by_user_id";
|
|
2675
|
+
var RUN_RESULT_SELECT = `SELECT ${RUN_RESULT_SELECT_COLUMNS} FROM run_results`;
|
|
2481
2676
|
var USER_SELECT = `SELECT ${USER_SELECT_COLUMNS} FROM users`;
|
|
2482
2677
|
var API_TOKEN_SELECT = `SELECT ${API_TOKEN_SELECT_COLUMNS} FROM api_tokens`;
|
|
2483
2678
|
var FOLDER_SELECT = `SELECT ${FOLDER_SELECT_COLUMNS} FROM folders`;
|
|
@@ -3883,6 +4078,93 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3883
4078
|
);
|
|
3884
4079
|
return rows.map(mapLlmUsageLogSqlRow);
|
|
3885
4080
|
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Lists run results saved by the given user, newest first.
|
|
4083
|
+
*/
|
|
4084
|
+
async listRunResultsForUser(userId) {
|
|
4085
|
+
const rows = await this.queryRows(
|
|
4086
|
+
`${RUN_RESULT_SELECT} WHERE created_by_user_id = ? ORDER BY created_at DESC`,
|
|
4087
|
+
[userId]
|
|
4088
|
+
);
|
|
4089
|
+
return rows.map(mapRunResultSqlRow);
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* Lists all run results for admin inspection, newest first.
|
|
4093
|
+
*/
|
|
4094
|
+
async listAllRunResults() {
|
|
4095
|
+
const rows = await this.queryRows(
|
|
4096
|
+
`${RUN_RESULT_SELECT} ORDER BY created_at DESC`
|
|
4097
|
+
);
|
|
4098
|
+
return rows.map(mapRunResultSqlRow);
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* Creates a standalone run result snapshot.
|
|
4102
|
+
*/
|
|
4103
|
+
async createRunResult(input, actingUserId) {
|
|
4104
|
+
const metadata = parseRunResultPayload(input.payload);
|
|
4105
|
+
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
4106
|
+
const id = randomUUID3();
|
|
4107
|
+
const now = /* @__PURE__ */ new Date();
|
|
4108
|
+
await this.executeStatement(
|
|
4109
|
+
`INSERT INTO run_results (
|
|
4110
|
+
id,
|
|
4111
|
+
kind,
|
|
4112
|
+
label,
|
|
4113
|
+
collection_name,
|
|
4114
|
+
request_name,
|
|
4115
|
+
summary_passed,
|
|
4116
|
+
summary_failed,
|
|
4117
|
+
summary_skipped,
|
|
4118
|
+
payload,
|
|
4119
|
+
created_at,
|
|
4120
|
+
created_by_user_id
|
|
4121
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4122
|
+
[
|
|
4123
|
+
id,
|
|
4124
|
+
metadata.kind,
|
|
4125
|
+
label,
|
|
4126
|
+
metadata.collectionName,
|
|
4127
|
+
metadata.requestName,
|
|
4128
|
+
metadata.summary.passed,
|
|
4129
|
+
metadata.summary.failed,
|
|
4130
|
+
metadata.summary.skipped,
|
|
4131
|
+
JSON.stringify(input.payload),
|
|
4132
|
+
now,
|
|
4133
|
+
actingUserId
|
|
4134
|
+
]
|
|
4135
|
+
);
|
|
4136
|
+
const rows = await this.queryRows(
|
|
4137
|
+
`${RUN_RESULT_SELECT} WHERE id = ?`,
|
|
4138
|
+
[id]
|
|
4139
|
+
);
|
|
4140
|
+
const row = rows[0];
|
|
4141
|
+
if (!row) {
|
|
4142
|
+
throw new Error("Run result not found after insert");
|
|
4143
|
+
}
|
|
4144
|
+
await this.recordAuditEntry(actingUserId, "create", "run_result", id);
|
|
4145
|
+
return mapRunResultSqlRow(row);
|
|
4146
|
+
}
|
|
4147
|
+
/**
|
|
4148
|
+
* Finds a run result by id.
|
|
4149
|
+
*/
|
|
4150
|
+
async findRunResultById(id) {
|
|
4151
|
+
const rows = await this.queryRows(
|
|
4152
|
+
`${RUN_RESULT_SELECT} WHERE id = ?`,
|
|
4153
|
+
[id]
|
|
4154
|
+
);
|
|
4155
|
+
const row = rows[0];
|
|
4156
|
+
return row ? mapRunResultSqlRow(row) : null;
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Deletes a run result by id.
|
|
4160
|
+
*/
|
|
4161
|
+
async deleteRunResult(id, actingUserId) {
|
|
4162
|
+
const result = await this.executeStatement("DELETE FROM run_results WHERE id = ?", [id]);
|
|
4163
|
+
if (result.affectedRows === 0) {
|
|
4164
|
+
throw new Error("Run result not found");
|
|
4165
|
+
}
|
|
4166
|
+
await this.recordAuditEntry(actingUserId, "delete", "run_result", id);
|
|
4167
|
+
}
|
|
3886
4168
|
/**
|
|
3887
4169
|
* Ensures the internal system user exists and caches its identifier.
|
|
3888
4170
|
*/
|
|
@@ -4229,6 +4511,22 @@ WHERE role = 'user'
|
|
|
4229
4511
|
AND snippet_access = '[]'
|
|
4230
4512
|
AND collection_access LIKE '%"*"%';
|
|
4231
4513
|
`.trim();
|
|
4514
|
+
var RUN_RESULTS_MIGRATION_SQL2 = `
|
|
4515
|
+
CREATE TABLE IF NOT EXISTS run_results (
|
|
4516
|
+
id TEXT PRIMARY KEY,
|
|
4517
|
+
kind TEXT NOT NULL CHECK (kind IN ('collection-run-results', 'request-run-results')),
|
|
4518
|
+
label TEXT NOT NULL,
|
|
4519
|
+
collection_name TEXT,
|
|
4520
|
+
request_name TEXT,
|
|
4521
|
+
summary_passed INT NOT NULL DEFAULT 0,
|
|
4522
|
+
summary_failed INT NOT NULL DEFAULT 0,
|
|
4523
|
+
summary_skipped INT NOT NULL DEFAULT 0,
|
|
4524
|
+
payload TEXT NOT NULL,
|
|
4525
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
4526
|
+
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL
|
|
4527
|
+
);
|
|
4528
|
+
CREATE INDEX IF NOT EXISTS run_results_created_idx ON run_results (created_at DESC);
|
|
4529
|
+
`.trim();
|
|
4232
4530
|
var POSTGRES_MIGRATIONS = [
|
|
4233
4531
|
USERS_MIGRATION_SQL2,
|
|
4234
4532
|
API_TOKENS_MIGRATION_SQL2,
|
|
@@ -4254,7 +4552,8 @@ var POSTGRES_MIGRATIONS = [
|
|
|
4254
4552
|
COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL2,
|
|
4255
4553
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL2,
|
|
4256
4554
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL2,
|
|
4257
|
-
USERS_SNIPPET_ACCESS_BACKFILL_SQL2
|
|
4555
|
+
USERS_SNIPPET_ACCESS_BACKFILL_SQL2,
|
|
4556
|
+
RUN_RESULTS_MIGRATION_SQL2
|
|
4258
4557
|
];
|
|
4259
4558
|
|
|
4260
4559
|
// src/db/postgres/schemas.ts
|
|
@@ -4279,6 +4578,8 @@ var { Pool } = pg;
|
|
|
4279
4578
|
var COLLECTION_SELECT2 = `SELECT ${COLLECTION_SELECT_COLUMNS} FROM collections`;
|
|
4280
4579
|
var ENVIRONMENT_SELECT2 = `SELECT ${ENVIRONMENT_SELECT_COLUMNS} FROM environments`;
|
|
4281
4580
|
var SNIPPET_SELECT2 = `SELECT ${SNIPPET_SELECT_COLUMNS} FROM snippets`;
|
|
4581
|
+
var RUN_RESULT_SELECT_COLUMNS2 = "id, kind, label, collection_name, request_name, summary_passed, summary_failed, summary_skipped, payload, created_at, created_by_user_id";
|
|
4582
|
+
var RUN_RESULT_SELECT2 = `SELECT ${RUN_RESULT_SELECT_COLUMNS2} FROM run_results`;
|
|
4282
4583
|
var USER_SELECT2 = `SELECT ${USER_SELECT_COLUMNS} FROM users`;
|
|
4283
4584
|
var API_TOKEN_SELECT2 = `SELECT ${API_TOKEN_SELECT_COLUMNS} FROM api_tokens`;
|
|
4284
4585
|
var FOLDER_SELECT2 = `SELECT ${FOLDER_SELECT_COLUMNS} FROM folders`;
|
|
@@ -5629,6 +5930,87 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5629
5930
|
);
|
|
5630
5931
|
return result.rows.map(mapLlmUsageLogSqlRow);
|
|
5631
5932
|
}
|
|
5933
|
+
/**
|
|
5934
|
+
* Lists run results saved by the given user, newest first.
|
|
5935
|
+
*/
|
|
5936
|
+
async listRunResultsForUser(userId) {
|
|
5937
|
+
const result = await this.query(
|
|
5938
|
+
`${RUN_RESULT_SELECT2} WHERE created_by_user_id = $1 ORDER BY created_at DESC`,
|
|
5939
|
+
[userId]
|
|
5940
|
+
);
|
|
5941
|
+
return result.rows.map(mapRunResultSqlRow);
|
|
5942
|
+
}
|
|
5943
|
+
/**
|
|
5944
|
+
* Lists all run results for admin inspection, newest first.
|
|
5945
|
+
*/
|
|
5946
|
+
async listAllRunResults() {
|
|
5947
|
+
const result = await this.query(
|
|
5948
|
+
`${RUN_RESULT_SELECT2} ORDER BY created_at DESC`
|
|
5949
|
+
);
|
|
5950
|
+
return result.rows.map(mapRunResultSqlRow);
|
|
5951
|
+
}
|
|
5952
|
+
/**
|
|
5953
|
+
* Creates a standalone run result snapshot.
|
|
5954
|
+
*/
|
|
5955
|
+
async createRunResult(input, actingUserId) {
|
|
5956
|
+
const metadata = parseRunResultPayload(input.payload);
|
|
5957
|
+
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
5958
|
+
const id = randomUUID4();
|
|
5959
|
+
const now = /* @__PURE__ */ new Date();
|
|
5960
|
+
const result = await this.query(
|
|
5961
|
+
`INSERT INTO run_results (
|
|
5962
|
+
id,
|
|
5963
|
+
kind,
|
|
5964
|
+
label,
|
|
5965
|
+
collection_name,
|
|
5966
|
+
request_name,
|
|
5967
|
+
summary_passed,
|
|
5968
|
+
summary_failed,
|
|
5969
|
+
summary_skipped,
|
|
5970
|
+
payload,
|
|
5971
|
+
created_at,
|
|
5972
|
+
created_by_user_id
|
|
5973
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
5974
|
+
RETURNING ${RUN_RESULT_SELECT_COLUMNS2}`,
|
|
5975
|
+
[
|
|
5976
|
+
id,
|
|
5977
|
+
metadata.kind,
|
|
5978
|
+
label,
|
|
5979
|
+
metadata.collectionName,
|
|
5980
|
+
metadata.requestName,
|
|
5981
|
+
metadata.summary.passed,
|
|
5982
|
+
metadata.summary.failed,
|
|
5983
|
+
metadata.summary.skipped,
|
|
5984
|
+
JSON.stringify(input.payload),
|
|
5985
|
+
now,
|
|
5986
|
+
actingUserId
|
|
5987
|
+
]
|
|
5988
|
+
);
|
|
5989
|
+
const row = result.rows[0];
|
|
5990
|
+
if (!row) {
|
|
5991
|
+
throw new Error("Run result not found after insert");
|
|
5992
|
+
}
|
|
5993
|
+
await this.recordAuditEntry(actingUserId, "create", "run_result", id);
|
|
5994
|
+
return mapRunResultSqlRow(row);
|
|
5995
|
+
}
|
|
5996
|
+
/**
|
|
5997
|
+
* Finds a run result by id.
|
|
5998
|
+
*/
|
|
5999
|
+
async findRunResultById(id) {
|
|
6000
|
+
const result = await this.query(`${RUN_RESULT_SELECT2} WHERE id = $1`, [id]);
|
|
6001
|
+
const row = result.rows[0];
|
|
6002
|
+
return row ? mapRunResultSqlRow(row) : null;
|
|
6003
|
+
}
|
|
6004
|
+
/**
|
|
6005
|
+
* Deletes a run result by id.
|
|
6006
|
+
*/
|
|
6007
|
+
async deleteRunResult(id, actingUserId) {
|
|
6008
|
+
const result = await this.query("DELETE FROM run_results WHERE id = $1", [id]);
|
|
6009
|
+
if ((result.rowCount ?? 0) === 0) {
|
|
6010
|
+
throw new Error("Run result not found");
|
|
6011
|
+
}
|
|
6012
|
+
await this.recordAuditEntry(actingUserId, "delete", "run_result", id);
|
|
6013
|
+
}
|
|
5632
6014
|
/**
|
|
5633
6015
|
* Returns the active pool or throws when connect has not been called.
|
|
5634
6016
|
*
|
|
@@ -6114,6 +6496,14 @@ var LLM_MODEL_CATALOG = [
|
|
|
6114
6496
|
function hasProviderKey(config, provider) {
|
|
6115
6497
|
return Boolean(config.providers[provider]?.apiKey.trim());
|
|
6116
6498
|
}
|
|
6499
|
+
function hasHubOpenAiProvider(config) {
|
|
6500
|
+
return hasProviderKey(config, "openai");
|
|
6501
|
+
}
|
|
6502
|
+
function getHubLlmCapabilities(config) {
|
|
6503
|
+
return {
|
|
6504
|
+
openai: hasProviderKey(config, "openai")
|
|
6505
|
+
};
|
|
6506
|
+
}
|
|
6117
6507
|
function listHubOfferedModels(config) {
|
|
6118
6508
|
const allowList = config.models ? new Set(config.models) : null;
|
|
6119
6509
|
return LLM_MODEL_CATALOG.filter((model) => {
|
|
@@ -6655,6 +7045,9 @@ function canListEnvironments(user) {
|
|
|
6655
7045
|
function canListSnippets(user) {
|
|
6656
7046
|
return canUseDataApi(user) || canUseManagementApi(user);
|
|
6657
7047
|
}
|
|
7048
|
+
function canListRunResults(user) {
|
|
7049
|
+
return canUseDataApi(user) || canUseManagementApi(user);
|
|
7050
|
+
}
|
|
6658
7051
|
function hasWildcardAccess(access) {
|
|
6659
7052
|
return access.includes("*");
|
|
6660
7053
|
}
|
|
@@ -6691,6 +7084,9 @@ function canDeleteCollection(user, collection) {
|
|
|
6691
7084
|
function canDeleteRequest(user, request) {
|
|
6692
7085
|
return canUseDataApi(user) && canAccessCollection(user, request.collectionId) && request.createdByUserId === user.id;
|
|
6693
7086
|
}
|
|
7087
|
+
function canDeleteRunResult(user, runResult) {
|
|
7088
|
+
return canUseDataApi(user) && runResult.createdByUserId === user.id;
|
|
7089
|
+
}
|
|
6694
7090
|
function canCreateCollection(user) {
|
|
6695
7091
|
return user.role === "user" && hasWildcardAccess(user.collectionAccess);
|
|
6696
7092
|
}
|
|
@@ -6923,8 +7319,12 @@ var llmModelSchema = z7.object({
|
|
|
6923
7319
|
label: z7.string(),
|
|
6924
7320
|
provider: z7.enum(["openai", "claude", "gemini"])
|
|
6925
7321
|
});
|
|
7322
|
+
var llmCapabilitiesSchema = z7.object({
|
|
7323
|
+
openai: z7.boolean()
|
|
7324
|
+
});
|
|
6926
7325
|
var listLlmModelsResponseSchema = z7.object({
|
|
6927
|
-
models: z7.array(llmModelSchema)
|
|
7326
|
+
models: z7.array(llmModelSchema),
|
|
7327
|
+
capabilities: llmCapabilitiesSchema
|
|
6928
7328
|
});
|
|
6929
7329
|
var llmUsageSummaryResponseSchema = z7.object({
|
|
6930
7330
|
period: z7.string(),
|
|
@@ -7115,6 +7515,48 @@ function serializeSavedRequest(record) {
|
|
|
7115
7515
|
updatedAt: record.updatedAt.toISOString()
|
|
7116
7516
|
};
|
|
7117
7517
|
}
|
|
7518
|
+
var runResultRecordSchema = z8.object({
|
|
7519
|
+
id: z8.string(),
|
|
7520
|
+
kind: z8.enum(["collection-run-results", "request-run-results"]),
|
|
7521
|
+
label: z8.string(),
|
|
7522
|
+
collectionName: z8.string().nullable(),
|
|
7523
|
+
requestName: z8.string().nullable(),
|
|
7524
|
+
summary: z8.object({
|
|
7525
|
+
passed: z8.number().int().nonnegative(),
|
|
7526
|
+
failed: z8.number().int().nonnegative(),
|
|
7527
|
+
skipped: z8.number().int().nonnegative()
|
|
7528
|
+
}),
|
|
7529
|
+
createdAt: timestampSchema,
|
|
7530
|
+
createdByUserId: z8.string().nullable()
|
|
7531
|
+
});
|
|
7532
|
+
var runResultDetailSchema = runResultRecordSchema.extend({
|
|
7533
|
+
payload: z8.record(z8.string(), z8.unknown())
|
|
7534
|
+
});
|
|
7535
|
+
var createRunResultBodySchema = z8.object({
|
|
7536
|
+
label: z8.string().trim().min(1).optional(),
|
|
7537
|
+
payload: z8.record(z8.string(), z8.unknown())
|
|
7538
|
+
});
|
|
7539
|
+
var listRunResultsResponseSchema = z8.object({
|
|
7540
|
+
runResults: z8.array(runResultRecordSchema)
|
|
7541
|
+
});
|
|
7542
|
+
function serializeRunResult(record) {
|
|
7543
|
+
return {
|
|
7544
|
+
id: record.id,
|
|
7545
|
+
kind: record.kind,
|
|
7546
|
+
label: record.label,
|
|
7547
|
+
collectionName: record.collectionName,
|
|
7548
|
+
requestName: record.requestName,
|
|
7549
|
+
summary: record.summary,
|
|
7550
|
+
createdAt: record.createdAt.toISOString(),
|
|
7551
|
+
createdByUserId: record.createdByUserId
|
|
7552
|
+
};
|
|
7553
|
+
}
|
|
7554
|
+
function serializeRunResultDetail(record) {
|
|
7555
|
+
return {
|
|
7556
|
+
...serializeRunResult(record),
|
|
7557
|
+
payload: record.payload
|
|
7558
|
+
};
|
|
7559
|
+
}
|
|
7118
7560
|
|
|
7119
7561
|
// src/server/routes/schemas/admin.ts
|
|
7120
7562
|
var adminResourceOptionSchema = z9.object({
|
|
@@ -7164,6 +7606,9 @@ var listAdminEnvironmentsResponseSchema = z9.object({
|
|
|
7164
7606
|
var listAdminSnippetsResponseSchema = z9.object({
|
|
7165
7607
|
snippets: z9.array(snippetRecordSchema)
|
|
7166
7608
|
});
|
|
7609
|
+
var listAdminRunResultsResponseSchema = z9.object({
|
|
7610
|
+
runResults: z9.array(runResultRecordSchema)
|
|
7611
|
+
});
|
|
7167
7612
|
var adminSnippetRecordSchema = snippetRecordSchema;
|
|
7168
7613
|
var listAdminLlmModelsResponseSchema = listLlmModelsResponseSchema;
|
|
7169
7614
|
var hubUserRecordSchema = z9.object({
|
|
@@ -7230,7 +7675,7 @@ var listAdminTokensResponseSchema = z9.object({
|
|
|
7230
7675
|
tokens: z9.array(hubApiTokenRecordSchema)
|
|
7231
7676
|
});
|
|
7232
7677
|
var reloadConfigSectionResultSchema = z9.object({
|
|
7233
|
-
section: z9.enum(["db", "redis", "llm", "plugins", "server"]),
|
|
7678
|
+
section: z9.enum(["db", "redis", "llm", "plugins", "docs", "server"]),
|
|
7234
7679
|
status: z9.enum(["reloaded", "unchanged", "failed", "restart-required"]),
|
|
7235
7680
|
error: z9.string().optional()
|
|
7236
7681
|
});
|
|
@@ -7722,6 +8167,71 @@ async function registerAdminRoutes(app, options) {
|
|
|
7722
8167
|
}
|
|
7723
8168
|
}
|
|
7724
8169
|
});
|
|
8170
|
+
routes.route({
|
|
8171
|
+
method: "GET",
|
|
8172
|
+
url: "/admin/run-results",
|
|
8173
|
+
schema: {
|
|
8174
|
+
response: {
|
|
8175
|
+
200: listAdminRunResultsResponseSchema,
|
|
8176
|
+
403: errorResponseSchema
|
|
8177
|
+
}
|
|
8178
|
+
},
|
|
8179
|
+
/**
|
|
8180
|
+
* Lists all run results for operator management.
|
|
8181
|
+
*/
|
|
8182
|
+
handler: async (request, reply) => {
|
|
8183
|
+
try {
|
|
8184
|
+
const user = requireAuthenticatedUser(request);
|
|
8185
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
|
|
8186
|
+
return;
|
|
8187
|
+
}
|
|
8188
|
+
const runResults = await db.listAllRunResults();
|
|
8189
|
+
return reply.send({
|
|
8190
|
+
runResults: runResults.map((record) => serializeRunResult(record))
|
|
8191
|
+
});
|
|
8192
|
+
} catch (error) {
|
|
8193
|
+
if (handleDbError(reply, error)) {
|
|
8194
|
+
return;
|
|
8195
|
+
}
|
|
8196
|
+
throw error;
|
|
8197
|
+
}
|
|
8198
|
+
}
|
|
8199
|
+
});
|
|
8200
|
+
routes.route({
|
|
8201
|
+
method: "DELETE",
|
|
8202
|
+
url: "/admin/run-results/:id",
|
|
8203
|
+
schema: {
|
|
8204
|
+
params: idParamSchema,
|
|
8205
|
+
response: {
|
|
8206
|
+
204: emptyResponseSchema,
|
|
8207
|
+
403: errorResponseSchema,
|
|
8208
|
+
404: errorResponseSchema
|
|
8209
|
+
}
|
|
8210
|
+
},
|
|
8211
|
+
/**
|
|
8212
|
+
* Deletes a run result regardless of creator ownership.
|
|
8213
|
+
*/
|
|
8214
|
+
handler: async (request, reply) => {
|
|
8215
|
+
try {
|
|
8216
|
+
const user = requireAuthenticatedUser(request);
|
|
8217
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
|
|
8218
|
+
return;
|
|
8219
|
+
}
|
|
8220
|
+
const record = await db.findRunResultById(request.params.id);
|
|
8221
|
+
if (!record) {
|
|
8222
|
+
void reply.code(404).send({ error: "Run result not found" });
|
|
8223
|
+
return;
|
|
8224
|
+
}
|
|
8225
|
+
await db.deleteRunResult(request.params.id, user.id);
|
|
8226
|
+
return reply.code(204).send(null);
|
|
8227
|
+
} catch (error) {
|
|
8228
|
+
if (handleDbError(reply, error)) {
|
|
8229
|
+
return;
|
|
8230
|
+
}
|
|
8231
|
+
throw error;
|
|
8232
|
+
}
|
|
8233
|
+
}
|
|
8234
|
+
});
|
|
7725
8235
|
routes.route({
|
|
7726
8236
|
method: "PUT",
|
|
7727
8237
|
url: "/admin/snippets/:id",
|
|
@@ -7905,7 +8415,7 @@ async function registerAdminRoutes(app, options) {
|
|
|
7905
8415
|
label: model.label,
|
|
7906
8416
|
provider: model.provider
|
|
7907
8417
|
}));
|
|
7908
|
-
return reply.send({ models });
|
|
8418
|
+
return reply.send({ models, capabilities: getHubLlmCapabilities(llm) });
|
|
7909
8419
|
}
|
|
7910
8420
|
});
|
|
7911
8421
|
routes.route({
|
|
@@ -9104,6 +9614,140 @@ async function registerRequestRoutes(app, db) {
|
|
|
9104
9614
|
});
|
|
9105
9615
|
}
|
|
9106
9616
|
|
|
9617
|
+
// src/server/routes/runResults.ts
|
|
9618
|
+
async function registerRunResultRoutes(app, db) {
|
|
9619
|
+
const routes = app.withTypeProvider();
|
|
9620
|
+
routes.route({
|
|
9621
|
+
method: "GET",
|
|
9622
|
+
url: "/run-results",
|
|
9623
|
+
schema: {
|
|
9624
|
+
response: {
|
|
9625
|
+
200: listRunResultsResponseSchema
|
|
9626
|
+
}
|
|
9627
|
+
},
|
|
9628
|
+
/**
|
|
9629
|
+
* Lists run results saved by the authenticated user token.
|
|
9630
|
+
*/
|
|
9631
|
+
handler: async (request, reply) => {
|
|
9632
|
+
try {
|
|
9633
|
+
const user = requireAuthenticatedUser(request);
|
|
9634
|
+
if (denyUnlessAllowed(reply, canListRunResults(user))) {
|
|
9635
|
+
return;
|
|
9636
|
+
}
|
|
9637
|
+
const runResults = await db.listRunResultsForUser(user.id);
|
|
9638
|
+
return reply.send({
|
|
9639
|
+
runResults: runResults.map((record) => serializeRunResult(record))
|
|
9640
|
+
});
|
|
9641
|
+
} catch (error) {
|
|
9642
|
+
if (handleDbError(reply, error)) {
|
|
9643
|
+
return;
|
|
9644
|
+
}
|
|
9645
|
+
throw error;
|
|
9646
|
+
}
|
|
9647
|
+
}
|
|
9648
|
+
});
|
|
9649
|
+
routes.route({
|
|
9650
|
+
method: "POST",
|
|
9651
|
+
url: "/run-results",
|
|
9652
|
+
schema: {
|
|
9653
|
+
body: createRunResultBodySchema,
|
|
9654
|
+
response: {
|
|
9655
|
+
200: runResultDetailSchema,
|
|
9656
|
+
400: errorResponseSchema
|
|
9657
|
+
}
|
|
9658
|
+
},
|
|
9659
|
+
/**
|
|
9660
|
+
* Saves a standalone run result snapshot.
|
|
9661
|
+
*/
|
|
9662
|
+
handler: async (request, reply) => {
|
|
9663
|
+
try {
|
|
9664
|
+
const user = requireAuthenticatedUser(request);
|
|
9665
|
+
if (denyUnlessAllowed(reply, canUseDataApi(user))) {
|
|
9666
|
+
return;
|
|
9667
|
+
}
|
|
9668
|
+
const record = await db.createRunResult(request.body, user.id);
|
|
9669
|
+
return reply.send(serializeRunResultDetail(record));
|
|
9670
|
+
} catch (error) {
|
|
9671
|
+
if (handleDbError(reply, error)) {
|
|
9672
|
+
return;
|
|
9673
|
+
}
|
|
9674
|
+
throw error;
|
|
9675
|
+
}
|
|
9676
|
+
}
|
|
9677
|
+
});
|
|
9678
|
+
routes.route({
|
|
9679
|
+
method: "GET",
|
|
9680
|
+
url: "/run-results/:id",
|
|
9681
|
+
schema: {
|
|
9682
|
+
params: idParamSchema,
|
|
9683
|
+
response: {
|
|
9684
|
+
200: runResultDetailSchema,
|
|
9685
|
+
404: errorResponseSchema
|
|
9686
|
+
}
|
|
9687
|
+
},
|
|
9688
|
+
/**
|
|
9689
|
+
* Loads a run result snapshot by id for shared deep links.
|
|
9690
|
+
*/
|
|
9691
|
+
handler: async (request, reply) => {
|
|
9692
|
+
try {
|
|
9693
|
+
const user = requireAuthenticatedUser(request);
|
|
9694
|
+
if (denyUnlessAllowed(reply, canUseDataApi(user))) {
|
|
9695
|
+
return;
|
|
9696
|
+
}
|
|
9697
|
+
const record = await db.findRunResultById(request.params.id);
|
|
9698
|
+
if (!record) {
|
|
9699
|
+
void reply.code(404).send({ error: "Run result not found" });
|
|
9700
|
+
return;
|
|
9701
|
+
}
|
|
9702
|
+
return reply.send(serializeRunResultDetail(record));
|
|
9703
|
+
} catch (error) {
|
|
9704
|
+
if (handleDbError(reply, error)) {
|
|
9705
|
+
return;
|
|
9706
|
+
}
|
|
9707
|
+
throw error;
|
|
9708
|
+
}
|
|
9709
|
+
}
|
|
9710
|
+
});
|
|
9711
|
+
routes.route({
|
|
9712
|
+
method: "DELETE",
|
|
9713
|
+
url: "/run-results/:id",
|
|
9714
|
+
schema: {
|
|
9715
|
+
params: idParamSchema,
|
|
9716
|
+
response: {
|
|
9717
|
+
204: emptyResponseSchema,
|
|
9718
|
+
403: errorResponseSchema,
|
|
9719
|
+
404: errorResponseSchema
|
|
9720
|
+
}
|
|
9721
|
+
},
|
|
9722
|
+
/**
|
|
9723
|
+
* Deletes a run result saved by the authenticated user when permitted.
|
|
9724
|
+
*/
|
|
9725
|
+
handler: async (request, reply) => {
|
|
9726
|
+
try {
|
|
9727
|
+
const user = requireAuthenticatedUser(request);
|
|
9728
|
+
if (denyUnlessAllowed(reply, canUseDataApi(user))) {
|
|
9729
|
+
return;
|
|
9730
|
+
}
|
|
9731
|
+
const record = await db.findRunResultById(request.params.id);
|
|
9732
|
+
if (!record) {
|
|
9733
|
+
void reply.code(404).send({ error: "Run result not found" });
|
|
9734
|
+
return;
|
|
9735
|
+
}
|
|
9736
|
+
if (denyUnlessAllowed(reply, canDeleteRunResult(user, record))) {
|
|
9737
|
+
return;
|
|
9738
|
+
}
|
|
9739
|
+
await db.deleteRunResult(request.params.id, user.id);
|
|
9740
|
+
return reply.code(204).send(null);
|
|
9741
|
+
} catch (error) {
|
|
9742
|
+
if (handleDbError(reply, error)) {
|
|
9743
|
+
return;
|
|
9744
|
+
}
|
|
9745
|
+
throw error;
|
|
9746
|
+
}
|
|
9747
|
+
}
|
|
9748
|
+
});
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9107
9751
|
// src/server/llm/client.ts
|
|
9108
9752
|
var OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
9109
9753
|
var CLAUDE_BASE_URL = "https://api.anthropic.com/v1";
|
|
@@ -9226,6 +9870,165 @@ async function runLlmCompletion(config, input) {
|
|
|
9226
9870
|
return parseCompletionResponse(json);
|
|
9227
9871
|
}
|
|
9228
9872
|
|
|
9873
|
+
// src/server/docs/docsSearch.ts
|
|
9874
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
9875
|
+
import path3 from "path";
|
|
9876
|
+
import { create, load, search } from "@orama/orama";
|
|
9877
|
+
import OpenAI from "openai";
|
|
9878
|
+
var DOCS_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
9879
|
+
var DOCS_EMBEDDING_DIMENSIONS = 1536;
|
|
9880
|
+
var DEFAULT_RESULT_LIMIT = 5;
|
|
9881
|
+
var MAX_RESULT_LIMIT = 10;
|
|
9882
|
+
var DEFAULT_SIMILARITY = 0.5;
|
|
9883
|
+
var SNIPPET_MAX_CHARS = 600;
|
|
9884
|
+
var DOCS_SEARCH_SCHEMA = {
|
|
9885
|
+
id: "string",
|
|
9886
|
+
source: "string",
|
|
9887
|
+
path: "string",
|
|
9888
|
+
url: "string",
|
|
9889
|
+
title: "string",
|
|
9890
|
+
heading: "string",
|
|
9891
|
+
content: "string",
|
|
9892
|
+
embedding: `vector[${DOCS_EMBEDDING_DIMENSIONS}]`
|
|
9893
|
+
};
|
|
9894
|
+
var cachedDb = null;
|
|
9895
|
+
var indexUnavailable = false;
|
|
9896
|
+
function getDocsSearchIndexPaths(docsConfig) {
|
|
9897
|
+
const paths = /* @__PURE__ */ new Set();
|
|
9898
|
+
if (docsConfig?.searchIndexPath) {
|
|
9899
|
+
paths.add(resolveDocsSearchIndexPath(docsConfig.searchIndexPath));
|
|
9900
|
+
}
|
|
9901
|
+
paths.add(DEFAULT_DOCS_SEARCH_INDEX_PATH);
|
|
9902
|
+
paths.add(path3.resolve(process.cwd(), "data/docsSearchIndex.json"));
|
|
9903
|
+
return [...paths];
|
|
9904
|
+
}
|
|
9905
|
+
function isDocsSearchIndexAvailable(docsConfig) {
|
|
9906
|
+
return getDocsSearchIndexPaths(docsConfig).some((candidate) => existsSync2(candidate));
|
|
9907
|
+
}
|
|
9908
|
+
function truncateSnippet(content, maxChars = SNIPPET_MAX_CHARS) {
|
|
9909
|
+
if (content.length <= maxChars) {
|
|
9910
|
+
return content;
|
|
9911
|
+
}
|
|
9912
|
+
return `${content.slice(0, maxChars)}...`;
|
|
9913
|
+
}
|
|
9914
|
+
function clampResultLimit(limit) {
|
|
9915
|
+
if (limit == null || !Number.isFinite(limit)) {
|
|
9916
|
+
return DEFAULT_RESULT_LIMIT;
|
|
9917
|
+
}
|
|
9918
|
+
return Math.min(Math.max(1, Math.floor(limit)), MAX_RESULT_LIMIT);
|
|
9919
|
+
}
|
|
9920
|
+
function loadDocsSearchIndex(docsConfig, paths) {
|
|
9921
|
+
if (cachedDb != null) {
|
|
9922
|
+
return cachedDb;
|
|
9923
|
+
}
|
|
9924
|
+
if (indexUnavailable) {
|
|
9925
|
+
throw new Error(
|
|
9926
|
+
"Documentation search index is not available. Deploy docsSearchIndex.json to the Team Hub server."
|
|
9927
|
+
);
|
|
9928
|
+
}
|
|
9929
|
+
const candidates = paths ?? getDocsSearchIndexPaths(docsConfig);
|
|
9930
|
+
const catalogPath = candidates.find((candidate) => existsSync2(candidate));
|
|
9931
|
+
if (catalogPath == null) {
|
|
9932
|
+
indexUnavailable = true;
|
|
9933
|
+
throw new Error(
|
|
9934
|
+
"Documentation search index is not available. Deploy docsSearchIndex.json to the Team Hub server."
|
|
9935
|
+
);
|
|
9936
|
+
}
|
|
9937
|
+
const raw = JSON.parse(readFileSync3(catalogPath, "utf8"));
|
|
9938
|
+
const db = create({ schema: DOCS_SEARCH_SCHEMA });
|
|
9939
|
+
load(db, raw);
|
|
9940
|
+
cachedDb = db;
|
|
9941
|
+
return db;
|
|
9942
|
+
}
|
|
9943
|
+
async function embedDocsQuery(query, apiKey) {
|
|
9944
|
+
const client = new OpenAI({ apiKey });
|
|
9945
|
+
const response = await client.embeddings.create({
|
|
9946
|
+
model: DOCS_EMBEDDING_MODEL,
|
|
9947
|
+
input: query
|
|
9948
|
+
});
|
|
9949
|
+
const embedding = response.data[0]?.embedding;
|
|
9950
|
+
if (!Array.isArray(embedding) || embedding.length !== DOCS_EMBEDDING_DIMENSIONS) {
|
|
9951
|
+
throw new Error("Unexpected embedding size from OpenAI.");
|
|
9952
|
+
}
|
|
9953
|
+
return embedding;
|
|
9954
|
+
}
|
|
9955
|
+
async function searchDocs(llmConfig, docsConfig, args) {
|
|
9956
|
+
const query = args.query?.trim();
|
|
9957
|
+
if (!query) {
|
|
9958
|
+
throw new Error("query is required.");
|
|
9959
|
+
}
|
|
9960
|
+
const apiKey = llmConfig.providers.openai?.apiKey.trim();
|
|
9961
|
+
if (!apiKey) {
|
|
9962
|
+
throw new Error(
|
|
9963
|
+
"OpenAI provider is not configured on this Team Hub. Documentation search requires an OpenAI API key."
|
|
9964
|
+
);
|
|
9965
|
+
}
|
|
9966
|
+
const db = loadDocsSearchIndex(docsConfig);
|
|
9967
|
+
const embedding = await embedDocsQuery(query, apiKey);
|
|
9968
|
+
const limit = clampResultLimit(args.limit);
|
|
9969
|
+
const results = search(db, {
|
|
9970
|
+
mode: "vector",
|
|
9971
|
+
vector: {
|
|
9972
|
+
value: embedding,
|
|
9973
|
+
property: "embedding"
|
|
9974
|
+
},
|
|
9975
|
+
similarity: DEFAULT_SIMILARITY,
|
|
9976
|
+
limit,
|
|
9977
|
+
...args.source != null ? { where: { source: args.source } } : {}
|
|
9978
|
+
});
|
|
9979
|
+
const resolved = results instanceof Promise ? await results : results;
|
|
9980
|
+
return resolved.hits.map((hit) => {
|
|
9981
|
+
const document = hit.document;
|
|
9982
|
+
return {
|
|
9983
|
+
title: document.title,
|
|
9984
|
+
heading: document.heading,
|
|
9985
|
+
url: document.url,
|
|
9986
|
+
source: document.source,
|
|
9987
|
+
path: document.path,
|
|
9988
|
+
score: hit.score,
|
|
9989
|
+
snippet: truncateSnippet(document.content)
|
|
9990
|
+
};
|
|
9991
|
+
});
|
|
9992
|
+
}
|
|
9993
|
+
|
|
9994
|
+
// src/server/llm/hubNativeTools.ts
|
|
9995
|
+
var HUB_NATIVE_TOOL_NAMES = ["search_docs"];
|
|
9996
|
+
function isHubNativeToolName(name) {
|
|
9997
|
+
return HUB_NATIVE_TOOL_NAMES.includes(name);
|
|
9998
|
+
}
|
|
9999
|
+
function canExecuteHubDocsSearch(llmConfig, docsConfig) {
|
|
10000
|
+
if (!llmConfig || !hasHubOpenAiProvider(llmConfig)) {
|
|
10001
|
+
return false;
|
|
10002
|
+
}
|
|
10003
|
+
return isDocsSearchIndexAvailable(docsConfig);
|
|
10004
|
+
}
|
|
10005
|
+
function filterClientToolsForHub(tools, llmConfig, docsConfig) {
|
|
10006
|
+
if (!tools || tools.length === 0) {
|
|
10007
|
+
return tools;
|
|
10008
|
+
}
|
|
10009
|
+
if (canExecuteHubDocsSearch(llmConfig, docsConfig)) {
|
|
10010
|
+
return tools;
|
|
10011
|
+
}
|
|
10012
|
+
const filtered = tools.filter((tool) => {
|
|
10013
|
+
const name = tool.function?.name;
|
|
10014
|
+
return name !== "search_docs";
|
|
10015
|
+
});
|
|
10016
|
+
return filtered.length > 0 ? filtered : void 0;
|
|
10017
|
+
}
|
|
10018
|
+
async function callHubNativeTool(name, args, llmConfig, docsConfig) {
|
|
10019
|
+
if (name !== "search_docs") {
|
|
10020
|
+
return JSON.stringify({ error: `Unknown hub-native tool: ${name}` });
|
|
10021
|
+
}
|
|
10022
|
+
try {
|
|
10023
|
+
const parsed = args;
|
|
10024
|
+
const hits = await searchDocs(llmConfig, docsConfig, parsed);
|
|
10025
|
+
return JSON.stringify(hits);
|
|
10026
|
+
} catch (error) {
|
|
10027
|
+
const message = error instanceof Error ? error.message : "Documentation search failed.";
|
|
10028
|
+
return JSON.stringify({ error: message });
|
|
10029
|
+
}
|
|
10030
|
+
}
|
|
10031
|
+
|
|
9229
10032
|
// src/server/llm/hubMcpToolNames.ts
|
|
9230
10033
|
var HUB_MCP_TOOL_PREFIX = "hubmcp__";
|
|
9231
10034
|
function encodeHubMcpToolName(serverIndex, toolName) {
|
|
@@ -9419,14 +10222,16 @@ function parseToolArguments(raw) {
|
|
|
9419
10222
|
return {};
|
|
9420
10223
|
}
|
|
9421
10224
|
}
|
|
9422
|
-
async function runHubChatStep(config, input, deps = {}) {
|
|
10225
|
+
async function runHubChatStep(config, input, deps = {}, docsConfig = null) {
|
|
9423
10226
|
const runCompletion = deps.runCompletion ?? runLlmCompletion;
|
|
9424
10227
|
const ensureConnections = deps.ensureConnections ?? ensureHubMcpConnections;
|
|
9425
10228
|
const listTools = deps.listTools ?? listHubMcpTools;
|
|
9426
10229
|
const callTool = deps.callTool ?? callHubMcpTool;
|
|
10230
|
+
const callNativeTool = deps.callNativeTool ?? callHubNativeTool;
|
|
9427
10231
|
await ensureConnections(config);
|
|
9428
10232
|
const hubTools = listTools();
|
|
9429
|
-
const
|
|
10233
|
+
const clientTools = filterClientToolsForHub(input.tools, config, docsConfig);
|
|
10234
|
+
const mergedTools = hubTools.length > 0 || (clientTools?.length ?? 0) > 0 ? [...hubTools, ...clientTools ?? []] : void 0;
|
|
9430
10235
|
let messages = [...input.messages];
|
|
9431
10236
|
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
9432
10237
|
let lastContent = null;
|
|
@@ -9446,8 +10251,11 @@ async function runHubChatStep(config, input, deps = {}) {
|
|
|
9446
10251
|
usage
|
|
9447
10252
|
};
|
|
9448
10253
|
}
|
|
10254
|
+
const nativeCalls = toolCalls.filter((call) => isHubNativeToolName(call.name));
|
|
9449
10255
|
const hubCalls = toolCalls.filter((call) => isHubMcpToolName(call.name));
|
|
9450
|
-
const passthroughCalls = toolCalls.filter(
|
|
10256
|
+
const passthroughCalls = toolCalls.filter(
|
|
10257
|
+
(call) => !isHubNativeToolName(call.name) && !isHubMcpToolName(call.name)
|
|
10258
|
+
);
|
|
9451
10259
|
if (passthroughCalls.length > 0) {
|
|
9452
10260
|
return {
|
|
9453
10261
|
content: result.content,
|
|
@@ -9455,14 +10263,28 @@ async function runHubChatStep(config, input, deps = {}) {
|
|
|
9455
10263
|
usage
|
|
9456
10264
|
};
|
|
9457
10265
|
}
|
|
10266
|
+
const serverCalls = [...nativeCalls, ...hubCalls];
|
|
9458
10267
|
messages = [
|
|
9459
10268
|
...messages,
|
|
9460
10269
|
{
|
|
9461
10270
|
role: "assistant",
|
|
9462
10271
|
content: result.content,
|
|
9463
|
-
tool_calls:
|
|
10272
|
+
tool_calls: serverCalls
|
|
9464
10273
|
}
|
|
9465
10274
|
];
|
|
10275
|
+
for (const call of nativeCalls) {
|
|
10276
|
+
const toolResult = await callNativeTool(
|
|
10277
|
+
call.name,
|
|
10278
|
+
parseToolArguments(call.arguments),
|
|
10279
|
+
config,
|
|
10280
|
+
docsConfig
|
|
10281
|
+
);
|
|
10282
|
+
messages.push({
|
|
10283
|
+
role: "tool",
|
|
10284
|
+
tool_call_id: call.id,
|
|
10285
|
+
content: toolResult
|
|
10286
|
+
});
|
|
10287
|
+
}
|
|
9466
10288
|
for (const call of hubCalls) {
|
|
9467
10289
|
const toolResult = await callTool(call.name, parseToolArguments(call.arguments));
|
|
9468
10290
|
messages.push({
|
|
@@ -9521,7 +10343,8 @@ async function registerLlmRoutes(app, options) {
|
|
|
9521
10343
|
id: model.id,
|
|
9522
10344
|
label: model.label,
|
|
9523
10345
|
provider: model.provider
|
|
9524
|
-
}))
|
|
10346
|
+
})),
|
|
10347
|
+
capabilities: getHubLlmCapabilities(llm)
|
|
9525
10348
|
});
|
|
9526
10349
|
}
|
|
9527
10350
|
});
|
|
@@ -9595,12 +10418,17 @@ async function registerLlmRoutes(app, options) {
|
|
|
9595
10418
|
if (isNewTurn && isOverMonthlyLimit(totalTokens, user.llmMonthlyTokenLimit)) {
|
|
9596
10419
|
return sendMonthlyLimitExceeded(reply);
|
|
9597
10420
|
}
|
|
9598
|
-
const result = await runHubChatStep(
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
10421
|
+
const result = await runHubChatStep(
|
|
10422
|
+
llm,
|
|
10423
|
+
{
|
|
10424
|
+
model,
|
|
10425
|
+
messages,
|
|
10426
|
+
tools,
|
|
10427
|
+
systemPrompt
|
|
10428
|
+
},
|
|
10429
|
+
{},
|
|
10430
|
+
options.getDocs()
|
|
10431
|
+
);
|
|
9604
10432
|
const catalogModel = getHubModelById(model);
|
|
9605
10433
|
if (!catalogModel) {
|
|
9606
10434
|
throw new Error(`Unknown hub model: ${model}`);
|
|
@@ -9746,7 +10574,12 @@ async function registerProtectedRoutes(app, options) {
|
|
|
9746
10574
|
await registerSnippetRoutes(app, options.db);
|
|
9747
10575
|
await registerFolderRoutes(app, options.db);
|
|
9748
10576
|
await registerRequestRoutes(app, options.db);
|
|
9749
|
-
await
|
|
10577
|
+
await registerRunResultRoutes(app, options.db);
|
|
10578
|
+
await registerLlmRoutes(app, {
|
|
10579
|
+
db: options.db,
|
|
10580
|
+
getLlm: options.getLlm,
|
|
10581
|
+
getDocs: options.getDocs
|
|
10582
|
+
});
|
|
9750
10583
|
await registerPluginsRoutes(app, { getPlugins: options.getPlugins });
|
|
9751
10584
|
}
|
|
9752
10585
|
async function registerRoutes(app, options) {
|
|
@@ -9781,6 +10614,7 @@ async function createServer(ctxOrConfig, options = {}) {
|
|
|
9781
10614
|
throttleStore,
|
|
9782
10615
|
getLlm: ctx ? () => ctx.getLlm() : () => legacyConfig?.llm ?? null,
|
|
9783
10616
|
getPlugins: ctx ? () => ctx.getPlugins() : () => legacyConfig?.plugins ?? null,
|
|
10617
|
+
getDocs: ctx ? () => ctx.getDocs() : () => legacyConfig?.docs ?? null,
|
|
9784
10618
|
reloadConfig: options.reloadConfig ?? (async () => ({ sections: [] }))
|
|
9785
10619
|
});
|
|
9786
10620
|
return app;
|
|
@@ -9967,7 +10801,8 @@ function createRuntimeContext(config, configPath) {
|
|
|
9967
10801
|
dbHolder: { underlying: createDatabase(config.db) },
|
|
9968
10802
|
throttleHolder: { underlying: createThrottleStore(config.redis) },
|
|
9969
10803
|
llm: config.llm,
|
|
9970
|
-
plugins: config.plugins
|
|
10804
|
+
plugins: config.plugins,
|
|
10805
|
+
docs: config.docs
|
|
9971
10806
|
};
|
|
9972
10807
|
const ctx = {
|
|
9973
10808
|
configPath: state.configPath,
|
|
@@ -9981,6 +10816,7 @@ function createRuntimeContext(config, configPath) {
|
|
|
9981
10816
|
throttleStore: createSwappableProxy(state.throttleHolder),
|
|
9982
10817
|
getLlm: () => state.llm,
|
|
9983
10818
|
getPlugins: () => state.plugins,
|
|
10819
|
+
getDocs: () => state.docs,
|
|
9984
10820
|
logger: createLogger(config.logging)
|
|
9985
10821
|
};
|
|
9986
10822
|
runtimeContextStates.set(ctx, state);
|
|
@@ -10054,6 +10890,8 @@ async function reloadRuntimeConfig(ctx) {
|
|
|
10054
10890
|
sections.push({ section: "llm", status: "reloaded" });
|
|
10055
10891
|
state.plugins = nextConfig.plugins;
|
|
10056
10892
|
sections.push({ section: "plugins", status: "reloaded" });
|
|
10893
|
+
state.docs = nextConfig.docs;
|
|
10894
|
+
sections.push({ section: "docs", status: "reloaded" });
|
|
10057
10895
|
sections.push(reloadServerSection(state, nextConfig));
|
|
10058
10896
|
return { sections };
|
|
10059
10897
|
}
|