@promptowl/contextnest-community 1.7.0 → 1.9.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/CONFIGURATION.md +191 -181
- package/LICENSE.md +142 -142
- package/README.md +224 -224
- package/dist/chunk-3JTODC3Y.js +48 -0
- package/dist/{chunk-43DOX4LH.js → chunk-BYS4HDME.js} +45 -49
- package/dist/{chunk-QMLAXQES.js → chunk-DPHV6Q26.js} +311 -19
- package/dist/chunk-FRQJWGN3.js +8 -0
- package/dist/{chunk-VD5QX2ZQ.js → chunk-HRQWNRVI.js} +4 -2
- package/dist/chunk-TIO5XOFD.js +103 -0
- package/dist/chunk-VC3QNHHB.js +664 -0
- package/dist/chunk-XQ46F76G.js +16 -0
- package/dist/client-GI74NPIW.js +11 -0
- package/dist/email-R7DFS6E5.js +6 -0
- package/dist/grants-service-MDLRPMZR.js +21 -0
- package/dist/index.js +2579 -230
- package/dist/{migrations.postgres-3HMGLKOV.js → migrations.postgres-ORSV7UFJ.js} +139 -1
- package/dist/{review-service-2FSKW425.js → review-service-A3XFQTJH.js} +6 -4
- package/dist/{stewardship-service-I2JAFYJU.js → stewardship-service-3KCVVXCW.js} +4 -2
- package/dist/{version-service-FJWVTZKR.js → version-service-6J4OWQL6.js} +3 -2
- package/dist/web3/assets/{hootie-_U3ECslt.svg → hootie-C2ocYkn4.svg} +8 -8
- package/dist/web3/assets/index-CHavHPIT.css +1 -0
- package/dist/web3/assets/index-DyvOBH92.js +934 -0
- package/dist/web3/index.html +14 -14
- package/package.json +161 -152
- package/dist/chunk-MOXICJPD.js +0 -303
- package/dist/web3/assets/index-24dBhQSQ.css +0 -1
- package/dist/web3/assets/index-DpM8J4RN.js +0 -889
package/dist/index.js
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
generateApiKey,
|
|
4
|
-
getKeyPrefix,
|
|
5
|
-
hashApiKey,
|
|
6
|
-
hashPassword,
|
|
7
|
-
parseBearerToken,
|
|
8
|
-
verifyPassword
|
|
9
|
-
} from "./chunk-XRK6SQSC.js";
|
|
10
2
|
import {
|
|
11
3
|
approve,
|
|
12
4
|
cancelReview,
|
|
13
5
|
getPendingReview,
|
|
14
6
|
getReviewHistory,
|
|
15
7
|
getReviewQueue,
|
|
8
|
+
notifyNestEvent,
|
|
16
9
|
reject,
|
|
17
10
|
safePublishDocument,
|
|
18
11
|
submitForReview
|
|
19
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-VC3QNHHB.js";
|
|
13
|
+
import {
|
|
14
|
+
createGrant,
|
|
15
|
+
deleteGrant,
|
|
16
|
+
grantCoversNode,
|
|
17
|
+
hasAnyGrant,
|
|
18
|
+
listGrants,
|
|
19
|
+
listUserGrants,
|
|
20
|
+
resolveNodeGrant
|
|
21
|
+
} from "./chunk-TIO5XOFD.js";
|
|
22
|
+
import {
|
|
23
|
+
generateApiKey,
|
|
24
|
+
getKeyPrefix,
|
|
25
|
+
hashApiKey,
|
|
26
|
+
hashPassword,
|
|
27
|
+
parseBearerToken,
|
|
28
|
+
verifyPassword
|
|
29
|
+
} from "./chunk-XRK6SQSC.js";
|
|
30
|
+
import {
|
|
31
|
+
normalizeEmail
|
|
32
|
+
} from "./chunk-FRQJWGN3.js";
|
|
20
33
|
import {
|
|
21
34
|
checkConflict,
|
|
22
35
|
createVersion,
|
|
@@ -25,14 +38,8 @@ import {
|
|
|
25
38
|
getDisplayStatus,
|
|
26
39
|
getVersions,
|
|
27
40
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-HRQWNRVI.js";
|
|
29
42
|
import {
|
|
30
|
-
AppError,
|
|
31
|
-
ConflictError,
|
|
32
|
-
ForbiddenError,
|
|
33
|
-
LockedError,
|
|
34
|
-
NotFoundError,
|
|
35
|
-
ValidationError,
|
|
36
43
|
canCreateInNest,
|
|
37
44
|
canManageStewards,
|
|
38
45
|
canUserAccess,
|
|
@@ -43,6 +50,7 @@ import {
|
|
|
43
50
|
createStewardRecord,
|
|
44
51
|
deleteNest,
|
|
45
52
|
disableStewardshipAndWipeGovernance,
|
|
53
|
+
docLink,
|
|
46
54
|
engineCache,
|
|
47
55
|
getCollaboratorRole,
|
|
48
56
|
getCurrentLicense,
|
|
@@ -65,6 +73,7 @@ import {
|
|
|
65
73
|
listStewards,
|
|
66
74
|
loadAccessConfig,
|
|
67
75
|
nestAllowsSelfApprove,
|
|
76
|
+
nestName,
|
|
68
77
|
nestStorageRoot,
|
|
69
78
|
permissionLevel,
|
|
70
79
|
removeSteward,
|
|
@@ -84,14 +93,26 @@ import {
|
|
|
84
93
|
updateSteward,
|
|
85
94
|
upsertEnvVar,
|
|
86
95
|
validateLicense
|
|
87
|
-
} from "./chunk-
|
|
96
|
+
} from "./chunk-BYS4HDME.js";
|
|
97
|
+
import {
|
|
98
|
+
AppError,
|
|
99
|
+
ConflictError,
|
|
100
|
+
ForbiddenError,
|
|
101
|
+
LockedError,
|
|
102
|
+
NotFoundError,
|
|
103
|
+
ValidationError
|
|
104
|
+
} from "./chunk-3JTODC3Y.js";
|
|
105
|
+
import {
|
|
106
|
+
insertOrIgnore,
|
|
107
|
+
nowExpr
|
|
108
|
+
} from "./chunk-XQ46F76G.js";
|
|
88
109
|
import {
|
|
89
110
|
config,
|
|
90
111
|
getDb,
|
|
91
112
|
initDb,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} from "./chunk-
|
|
113
|
+
isEmailListish,
|
|
114
|
+
isEmailish
|
|
115
|
+
} from "./chunk-DPHV6Q26.js";
|
|
95
116
|
import {
|
|
96
117
|
ANON_EMAIL,
|
|
97
118
|
ANON_USER_ID
|
|
@@ -101,7 +122,8 @@ import {
|
|
|
101
122
|
import { serve } from "@hono/node-server";
|
|
102
123
|
|
|
103
124
|
// src/app.ts
|
|
104
|
-
import { Hono as
|
|
125
|
+
import { Hono as Hono16 } from "hono";
|
|
126
|
+
import { LinearRouter } from "hono/router/linear-router";
|
|
105
127
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
106
128
|
import { cors } from "hono/cors";
|
|
107
129
|
|
|
@@ -227,11 +249,6 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
227
249
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
228
250
|
});
|
|
229
251
|
|
|
230
|
-
// src/shared/email.ts
|
|
231
|
-
function normalizeEmail(email) {
|
|
232
|
-
return email.trim().toLowerCase();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
252
|
// src/shared/rate-limit.ts
|
|
236
253
|
var buckets = /* @__PURE__ */ new Map();
|
|
237
254
|
function liveBucket(key, cutoff) {
|
|
@@ -1084,7 +1101,8 @@ async function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
|
1084
1101
|
return await getApprovedVersion(nestId, nodeId) !== null;
|
|
1085
1102
|
}
|
|
1086
1103
|
if (!await isStewardshipEnabled(nestId)) return true;
|
|
1087
|
-
|
|
1104
|
+
if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
|
|
1105
|
+
return await resolveNodeGrant(nestId, userId, nodeId) !== null;
|
|
1088
1106
|
}
|
|
1089
1107
|
async function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
1090
1108
|
if (await isPublicReader(nestId, userId)) {
|
|
@@ -1097,9 +1115,10 @@ async function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
|
1097
1115
|
return filtered;
|
|
1098
1116
|
}
|
|
1099
1117
|
if (!await isStewardshipEnabled(nestId)) return nodes;
|
|
1118
|
+
const grants = await listUserGrants(nestId, userId);
|
|
1100
1119
|
const accessible = [];
|
|
1101
1120
|
for (const n of nodes) {
|
|
1102
|
-
if ((await canUserAccess(nestId, n.id, userEmail)).allowed) {
|
|
1121
|
+
if ((await canUserAccess(nestId, n.id, userEmail)).allowed || grantCoversNode(grants, n.id)) {
|
|
1103
1122
|
accessible.push(n);
|
|
1104
1123
|
}
|
|
1105
1124
|
}
|
|
@@ -1289,7 +1308,7 @@ async function approveExternalEdit(input) {
|
|
|
1289
1308
|
const node = await storage.readDocument(input.documentId);
|
|
1290
1309
|
const versionNum = result.versionEntry.version;
|
|
1291
1310
|
const tags = node.frontmatter.tags || [];
|
|
1292
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1311
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-6J4OWQL6.js");
|
|
1293
1312
|
await createVersion2({
|
|
1294
1313
|
nestId: input.nestId,
|
|
1295
1314
|
nodeId: input.documentId,
|
|
@@ -1375,6 +1394,10 @@ function bodyOnly(nodeId, raw) {
|
|
|
1375
1394
|
return raw;
|
|
1376
1395
|
}
|
|
1377
1396
|
}
|
|
1397
|
+
var RUNNABLE_NODE_TYPES = ["agent", "skill"];
|
|
1398
|
+
function isRunnableType(type) {
|
|
1399
|
+
return RUNNABLE_NODE_TYPES.includes(type || "");
|
|
1400
|
+
}
|
|
1378
1401
|
function toNodeResponse(node) {
|
|
1379
1402
|
const fm = node.frontmatter;
|
|
1380
1403
|
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
@@ -1391,14 +1414,20 @@ function toNodeResponse(node) {
|
|
|
1391
1414
|
created_at: fm.created_at,
|
|
1392
1415
|
updated_at: fm.updated_at,
|
|
1393
1416
|
content: node.body || "",
|
|
1394
|
-
pendingChange: node.pendingChange ?? void 0
|
|
1417
|
+
pendingChange: node.pendingChange ?? void 0,
|
|
1418
|
+
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1419
|
+
// number — normalize to string at the API boundary.
|
|
1420
|
+
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
|
|
1395
1421
|
};
|
|
1396
1422
|
}
|
|
1397
1423
|
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1398
1424
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1399
1425
|
let documents = await storage.discoverDocuments();
|
|
1400
1426
|
if (filters.type) {
|
|
1401
|
-
|
|
1427
|
+
const wanted = new Set(
|
|
1428
|
+
Array.isArray(filters.type) ? filters.type : [filters.type]
|
|
1429
|
+
);
|
|
1430
|
+
documents = documents.filter((n) => wanted.has(n.frontmatter.type));
|
|
1402
1431
|
}
|
|
1403
1432
|
if (filters.tag) {
|
|
1404
1433
|
const tag = normalizeTag2(filters.tag);
|
|
@@ -1407,7 +1436,11 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
1407
1436
|
);
|
|
1408
1437
|
}
|
|
1409
1438
|
const userEmail = await resolveCallerEmail(userId);
|
|
1410
|
-
|
|
1439
|
+
let accessible = await filterAccessible(nestId, userId, userEmail, documents);
|
|
1440
|
+
if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
|
|
1441
|
+
const grants = await listUserGrants(nestId, userId);
|
|
1442
|
+
accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
|
|
1443
|
+
}
|
|
1411
1444
|
const publicReader = await isPublicReader(nestId, userId);
|
|
1412
1445
|
const enriched = await Promise.all(
|
|
1413
1446
|
accessible.map(async (doc) => {
|
|
@@ -1464,6 +1497,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1464
1497
|
}
|
|
1465
1498
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1466
1499
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1500
|
+
if (input.schedule !== void 0 && !isRunnableType(input.type)) {
|
|
1501
|
+
throw new ValidationError(
|
|
1502
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1467
1505
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1468
1506
|
const initialStatus = hasStewards ? "draft" : "published";
|
|
1469
1507
|
const initialVersion = hasStewards ? 1 : 0;
|
|
@@ -1478,7 +1516,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1478
1516
|
version: initialVersion,
|
|
1479
1517
|
created_at: now,
|
|
1480
1518
|
updated_at: now,
|
|
1481
|
-
metadata: {
|
|
1519
|
+
metadata: {
|
|
1520
|
+
owners: ["*"],
|
|
1521
|
+
scope: input.scope || "team",
|
|
1522
|
+
...input.schedule ? { schedule: input.schedule } : {}
|
|
1523
|
+
}
|
|
1482
1524
|
},
|
|
1483
1525
|
body: input.content,
|
|
1484
1526
|
rawContent: ""
|
|
@@ -1545,9 +1587,18 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1545
1587
|
return 0;
|
|
1546
1588
|
}
|
|
1547
1589
|
let registered = 0;
|
|
1548
|
-
for (
|
|
1590
|
+
for (let doc of docs) {
|
|
1549
1591
|
const nodeId = doc.id;
|
|
1550
1592
|
if (await getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1593
|
+
if (!doc.frontmatter?.title) {
|
|
1594
|
+
const title = nodeId.split("/").pop() || nodeId;
|
|
1595
|
+
doc = { ...doc, frontmatter: { ...doc.frontmatter, title } };
|
|
1596
|
+
try {
|
|
1597
|
+
await storage.writeDocument(nodeId, serializeDocument(doc));
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
console.error("import: failed to persist filename title", nodeId, err);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1551
1602
|
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
1552
1603
|
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
1553
1604
|
const fmVersion = Number(doc.frontmatter?.version);
|
|
@@ -1605,6 +1656,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1605
1656
|
if (patch.title) {
|
|
1606
1657
|
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1607
1658
|
}
|
|
1659
|
+
if (patch.schedule !== void 0) {
|
|
1660
|
+
if (!isRunnableType(node.frontmatter.type)) {
|
|
1661
|
+
throw new ValidationError(
|
|
1662
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
1666
|
+
if (patch.schedule === "") delete metadata.schedule;
|
|
1667
|
+
else metadata.schedule = patch.schedule;
|
|
1668
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
1669
|
+
}
|
|
1608
1670
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1609
1671
|
const currentTags = node.frontmatter.tags || [];
|
|
1610
1672
|
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
@@ -1849,13 +1911,13 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
1849
1911
|
}
|
|
1850
1912
|
const segments = folderName.split("/").filter(Boolean);
|
|
1851
1913
|
const baseName = segments[segments.length - 1] || folderName;
|
|
1852
|
-
const
|
|
1853
|
-
if (
|
|
1914
|
+
const nestName2 = await uniqueNestName(userId, baseName);
|
|
1915
|
+
if (nestName2 !== baseName) {
|
|
1854
1916
|
console.log(
|
|
1855
|
-
`[unsynced] name "${baseName}" already in use, using "${
|
|
1917
|
+
`[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
|
|
1856
1918
|
);
|
|
1857
1919
|
}
|
|
1858
|
-
const nest = await importNest(userId,
|
|
1920
|
+
const nest = await importNest(userId, nestName2, files);
|
|
1859
1921
|
console.log(
|
|
1860
1922
|
`[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
|
|
1861
1923
|
);
|
|
@@ -1913,7 +1975,14 @@ nestRoutes.get("/", async (c) => {
|
|
|
1913
1975
|
const row = await db.get(ownerEmailSql, [n.user_id]);
|
|
1914
1976
|
owner_email = row?.email ?? null;
|
|
1915
1977
|
}
|
|
1916
|
-
return {
|
|
1978
|
+
return {
|
|
1979
|
+
...n,
|
|
1980
|
+
permission,
|
|
1981
|
+
is_owner,
|
|
1982
|
+
owner_email,
|
|
1983
|
+
roles,
|
|
1984
|
+
stewardship_enabled: await isStewardshipEnabled(n.id)
|
|
1985
|
+
};
|
|
1917
1986
|
};
|
|
1918
1987
|
const seen = /* @__PURE__ */ new Set();
|
|
1919
1988
|
const out = [];
|
|
@@ -2165,12 +2234,36 @@ async function addCollaborator(params) {
|
|
|
2165
2234
|
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
|
|
2166
2235
|
[collabId, nestId, userId, params.permission, granterId]
|
|
2167
2236
|
);
|
|
2237
|
+
const shareNestName = await nestName(nestId);
|
|
2238
|
+
void notifyNestEvent(
|
|
2239
|
+
nestId,
|
|
2240
|
+
`:key: ${params.email || userId} added as *${params.permission}*${params.grantedByEmail ? ` by ${params.grantedByEmail}` : ""}`,
|
|
2241
|
+
{
|
|
2242
|
+
status: "shared",
|
|
2243
|
+
docTitle: shareNestName,
|
|
2244
|
+
path: shareNestName,
|
|
2245
|
+
link: docLink(nestId, void 0, params.baseUrl),
|
|
2246
|
+
actor: params.email || userId,
|
|
2247
|
+
permission: params.permission,
|
|
2248
|
+
by: params.grantedByEmail || void 0
|
|
2249
|
+
}
|
|
2250
|
+
);
|
|
2168
2251
|
return await db.get(
|
|
2169
2252
|
"SELECT * FROM nest_collaborators WHERE id = ?",
|
|
2170
2253
|
[collabId]
|
|
2171
2254
|
);
|
|
2172
2255
|
}
|
|
2173
2256
|
|
|
2257
|
+
// src/shared/base-url.ts
|
|
2258
|
+
function requestBaseUrl(reqUrl) {
|
|
2259
|
+
if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
|
|
2260
|
+
try {
|
|
2261
|
+
return new URL(reqUrl).origin;
|
|
2262
|
+
} catch {
|
|
2263
|
+
return "";
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2174
2267
|
// src/nests/sharing-routes.ts
|
|
2175
2268
|
var sharingRoutes = new Hono3();
|
|
2176
2269
|
sharingRoutes.get("/collaborators", async (c) => {
|
|
@@ -2207,7 +2300,8 @@ sharingRoutes.post("/collaborators", async (c) => {
|
|
|
2207
2300
|
grantedByUserId: c.get("userId"),
|
|
2208
2301
|
// Enforce the escalation cap against the caller's own nest permission
|
|
2209
2302
|
// (set by the access guard) — a write collaborator can't grant admin.
|
|
2210
|
-
callerPermission: c.get("nestPermission")
|
|
2303
|
+
callerPermission: c.get("nestPermission"),
|
|
2304
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
2211
2305
|
});
|
|
2212
2306
|
return c.json({ collaborator: collab }, 201);
|
|
2213
2307
|
});
|
|
@@ -2243,15 +2337,93 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
2243
2337
|
throw new ValidationError("visibility must be private or public");
|
|
2244
2338
|
}
|
|
2245
2339
|
const db = getDb();
|
|
2246
|
-
|
|
2340
|
+
const nestId = c.req.param("nestId");
|
|
2341
|
+
const result = await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
|
|
2247
2342
|
body.visibility,
|
|
2248
|
-
|
|
2343
|
+
nestId
|
|
2344
|
+
]);
|
|
2345
|
+
if (result.changes === 0) {
|
|
2346
|
+
throw new NotFoundError("Nest not found");
|
|
2347
|
+
}
|
|
2348
|
+
const row = await db.get("SELECT visibility FROM nests WHERE id = ?", [
|
|
2349
|
+
nestId
|
|
2249
2350
|
]);
|
|
2250
|
-
return c.json({ visibility:
|
|
2351
|
+
return c.json({ visibility: row.visibility });
|
|
2251
2352
|
});
|
|
2252
2353
|
|
|
2253
|
-
// src/
|
|
2354
|
+
// src/nests/grant-routes.ts
|
|
2254
2355
|
import { Hono as Hono4 } from "hono";
|
|
2356
|
+
import { v4 as uuid3 } from "uuid";
|
|
2357
|
+
var grantRoutes = new Hono4();
|
|
2358
|
+
async function requireManage(c, nestId) {
|
|
2359
|
+
if (!await canManageStewards(nestId, c.get("userId"))) {
|
|
2360
|
+
throw new ForbiddenError(
|
|
2361
|
+
"Only a nest admin, the nest owner, or the server admin can manage sharing."
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
async function resolveGrantee(email) {
|
|
2366
|
+
const db = getDb();
|
|
2367
|
+
const e = normalizeEmail(email);
|
|
2368
|
+
const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
|
|
2369
|
+
if (existing) return existing.id;
|
|
2370
|
+
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
2371
|
+
const id = uuid3();
|
|
2372
|
+
await db.run(
|
|
2373
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
2374
|
+
[id, e, null, await hashPassword2(uuid3())]
|
|
2375
|
+
);
|
|
2376
|
+
return id;
|
|
2377
|
+
}
|
|
2378
|
+
grantRoutes.post("/", async (c) => {
|
|
2379
|
+
const nestId = c.req.param("nestId");
|
|
2380
|
+
await requireManage(c, nestId);
|
|
2381
|
+
const body = await c.req.json();
|
|
2382
|
+
const targetType = body.target_type;
|
|
2383
|
+
const target = (body.target || "").trim();
|
|
2384
|
+
const role = body.role || "read";
|
|
2385
|
+
if (!["document", "folder"].includes(targetType)) {
|
|
2386
|
+
throw new ValidationError("target_type must be document | folder");
|
|
2387
|
+
}
|
|
2388
|
+
if (!target) throw new ValidationError("target is required");
|
|
2389
|
+
if (!body.email?.trim()) throw new ValidationError("email is required");
|
|
2390
|
+
if (!["read", "write"].includes(role)) {
|
|
2391
|
+
throw new ValidationError("role must be read | write");
|
|
2392
|
+
}
|
|
2393
|
+
const userId = await resolveGrantee(body.email);
|
|
2394
|
+
const grant = await createGrant({
|
|
2395
|
+
nestId,
|
|
2396
|
+
targetType,
|
|
2397
|
+
target,
|
|
2398
|
+
userId,
|
|
2399
|
+
role,
|
|
2400
|
+
grantedBy: await resolveCallerEmail(c.get("userId"))
|
|
2401
|
+
});
|
|
2402
|
+
return c.json({ grant }, 201);
|
|
2403
|
+
});
|
|
2404
|
+
grantRoutes.get("/", async (c) => {
|
|
2405
|
+
const nestId = c.req.param("nestId");
|
|
2406
|
+
await requireManage(c, nestId);
|
|
2407
|
+
const grants = await listGrants(nestId);
|
|
2408
|
+
const db = getDb();
|
|
2409
|
+
const withEmail = await Promise.all(
|
|
2410
|
+
grants.map(async (g) => {
|
|
2411
|
+
const u = await db.get("SELECT email FROM users WHERE id = ?", [g.user_id]);
|
|
2412
|
+
return { ...g, email: u?.email ?? null };
|
|
2413
|
+
})
|
|
2414
|
+
);
|
|
2415
|
+
return c.json({ count: withEmail.length, grants: withEmail });
|
|
2416
|
+
});
|
|
2417
|
+
grantRoutes.delete("/:id", async (c) => {
|
|
2418
|
+
const nestId = c.req.param("nestId");
|
|
2419
|
+
await requireManage(c, nestId);
|
|
2420
|
+
const ok = await deleteGrant(nestId, c.req.param("id"));
|
|
2421
|
+
if (!ok) throw new NotFoundError("Grant not found");
|
|
2422
|
+
return c.json({ deleted: true });
|
|
2423
|
+
});
|
|
2424
|
+
|
|
2425
|
+
// src/nodes/routes.ts
|
|
2426
|
+
import { Hono as Hono5 } from "hono";
|
|
2255
2427
|
|
|
2256
2428
|
// src/nodes/markdown-export.ts
|
|
2257
2429
|
function nodeToMarkdown(node) {
|
|
@@ -2274,7 +2446,7 @@ function isMarkdownFormat(c) {
|
|
|
2274
2446
|
}
|
|
2275
2447
|
|
|
2276
2448
|
// src/annotations/service.ts
|
|
2277
|
-
import { v4 as
|
|
2449
|
+
import { v4 as uuid4 } from "uuid";
|
|
2278
2450
|
|
|
2279
2451
|
// src/annotations/projection.ts
|
|
2280
2452
|
var MAX_CONTEXT_CHARS = 250;
|
|
@@ -2424,7 +2596,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
|
|
|
2424
2596
|
throw new Error("comment body is required");
|
|
2425
2597
|
}
|
|
2426
2598
|
const db = getDb();
|
|
2427
|
-
const id =
|
|
2599
|
+
const id = uuid4();
|
|
2428
2600
|
const anchor = clampAnchor(input.anchor);
|
|
2429
2601
|
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
2430
2602
|
await db.transaction(async (tx) => {
|
|
@@ -2443,7 +2615,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
|
|
|
2443
2615
|
);
|
|
2444
2616
|
await tx.run(
|
|
2445
2617
|
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
2446
|
-
[
|
|
2618
|
+
[uuid4(), id, authorEmail, body]
|
|
2447
2619
|
);
|
|
2448
2620
|
});
|
|
2449
2621
|
return rowToThread(await getThreadRow(id));
|
|
@@ -2463,7 +2635,7 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
|
2463
2635
|
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
2464
2636
|
await getDb().run(
|
|
2465
2637
|
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
2466
|
-
[
|
|
2638
|
+
[uuid4(), threadId, authorEmail, trimmed]
|
|
2467
2639
|
);
|
|
2468
2640
|
return rowToThread(await getThreadRow(threadId));
|
|
2469
2641
|
}
|
|
@@ -2562,7 +2734,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
|
2562
2734
|
}
|
|
2563
2735
|
|
|
2564
2736
|
// src/nodes/routes.ts
|
|
2565
|
-
var nodeRoutes = new
|
|
2737
|
+
var nodeRoutes = new Hono5();
|
|
2566
2738
|
function nodeAsMarkdown(response, nodeId) {
|
|
2567
2739
|
return nodeToMarkdown({
|
|
2568
2740
|
id: nodeId,
|
|
@@ -2597,6 +2769,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2597
2769
|
tags: body.tags,
|
|
2598
2770
|
scope: body.scope,
|
|
2599
2771
|
status: body.status,
|
|
2772
|
+
schedule: body.schedule,
|
|
2600
2773
|
folder: body.folder
|
|
2601
2774
|
},
|
|
2602
2775
|
authorEmail
|
|
@@ -2614,7 +2787,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2614
2787
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2615
2788
|
const nestId = c.req.param("nestId");
|
|
2616
2789
|
const nodeId = c.req.param("nodeId");
|
|
2617
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
2790
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3KCVVXCW.js");
|
|
2618
2791
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
2619
2792
|
nestId,
|
|
2620
2793
|
nodeId
|
|
@@ -2635,7 +2808,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
2635
2808
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
2636
2809
|
const nestId = c.req.param("nestId");
|
|
2637
2810
|
const nodeId = c.req.param("nodeId");
|
|
2638
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
2811
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-6J4OWQL6.js");
|
|
2639
2812
|
const allVersions = await getVersions2(nestId, nodeId);
|
|
2640
2813
|
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
2641
2814
|
const db = getDb();
|
|
@@ -2667,7 +2840,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
2667
2840
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2668
2841
|
const nestId = c.req.param("nestId");
|
|
2669
2842
|
const nodeId = c.req.param("nodeId");
|
|
2670
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
2843
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-A3XFQTJH.js");
|
|
2671
2844
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
2672
2845
|
return c.json({ reviews: history });
|
|
2673
2846
|
});
|
|
@@ -2814,7 +2987,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
2814
2987
|
tags: body.tags,
|
|
2815
2988
|
title: body.title,
|
|
2816
2989
|
status: body.status,
|
|
2817
|
-
changeNote: body.changeNote
|
|
2990
|
+
changeNote: body.changeNote,
|
|
2991
|
+
schedule: body.schedule
|
|
2818
2992
|
},
|
|
2819
2993
|
authorEmail
|
|
2820
2994
|
);
|
|
@@ -2880,6 +3054,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
2880
3054
|
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2881
3055
|
[nestId, derivedId]
|
|
2882
3056
|
);
|
|
3057
|
+
await tx.run(
|
|
3058
|
+
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
3059
|
+
VALUES (?, ?, ?, ?)
|
|
3060
|
+
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
3061
|
+
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
3062
|
+
[nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
|
|
3063
|
+
);
|
|
2883
3064
|
});
|
|
2884
3065
|
await trackEvent("node.delete", { nestId, nodeId });
|
|
2885
3066
|
return c.json({ deleted: true });
|
|
@@ -2892,13 +3073,13 @@ async function getUserEmail(c) {
|
|
|
2892
3073
|
}
|
|
2893
3074
|
|
|
2894
3075
|
// src/annotations/routes.ts
|
|
2895
|
-
import { Hono as
|
|
3076
|
+
import { Hono as Hono6 } from "hono";
|
|
2896
3077
|
|
|
2897
3078
|
// src/annotations/types.ts
|
|
2898
3079
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
2899
3080
|
|
|
2900
3081
|
// src/annotations/routes.ts
|
|
2901
|
-
var annotationRoutes = new
|
|
3082
|
+
var annotationRoutes = new Hono6();
|
|
2902
3083
|
function getNodeId(c) {
|
|
2903
3084
|
const raw = c.req.param("nodeId");
|
|
2904
3085
|
try {
|
|
@@ -3025,7 +3206,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
|
3025
3206
|
});
|
|
3026
3207
|
|
|
3027
3208
|
// src/nodes/query-routes.ts
|
|
3028
|
-
import { Hono as
|
|
3209
|
+
import { Hono as Hono7 } from "hono";
|
|
3029
3210
|
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
3030
3211
|
|
|
3031
3212
|
// src/nodes/prompt-compiler.ts
|
|
@@ -3247,6 +3428,10 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
|
3247
3428
|
// src/nodes/graph-service.ts
|
|
3248
3429
|
var MAX_GRAPH_NODES = 150;
|
|
3249
3430
|
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
3431
|
+
function bareSlug(idOrTarget) {
|
|
3432
|
+
const idx = idOrTarget.lastIndexOf("/");
|
|
3433
|
+
return idx >= 0 ? idOrTarget.slice(idx + 1) : idOrTarget;
|
|
3434
|
+
}
|
|
3250
3435
|
function extractWikiTargets(body) {
|
|
3251
3436
|
const out = [];
|
|
3252
3437
|
if (!body) return out;
|
|
@@ -3264,9 +3449,12 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3264
3449
|
const accessible = await filterAccessible(nestId, userId, userEmail, docs);
|
|
3265
3450
|
const idSet = new Set(accessible.map((d) => d.id));
|
|
3266
3451
|
const titleToId = /* @__PURE__ */ new Map();
|
|
3452
|
+
const slugToId = /* @__PURE__ */ new Map();
|
|
3267
3453
|
for (const d of accessible) {
|
|
3268
3454
|
const t = (d.frontmatter?.title || "").toLowerCase().trim();
|
|
3269
3455
|
if (t && !titleToId.has(t)) titleToId.set(t, d.id);
|
|
3456
|
+
const slug = bareSlug(d.id).toLowerCase();
|
|
3457
|
+
if (slug && !slugToId.has(slug)) slugToId.set(slug, d.id);
|
|
3270
3458
|
}
|
|
3271
3459
|
const allNodes = accessible.map((d) => ({
|
|
3272
3460
|
id: d.id,
|
|
@@ -3278,7 +3466,8 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3278
3466
|
const seen = /* @__PURE__ */ new Set();
|
|
3279
3467
|
for (const d of accessible) {
|
|
3280
3468
|
for (const raw of extractWikiTargets(d.body || "")) {
|
|
3281
|
-
const
|
|
3469
|
+
const norm = raw.toLowerCase().trim();
|
|
3470
|
+
const targetId = idSet.has(raw) ? raw : titleToId.get(norm) ?? slugToId.get(bareSlug(raw).toLowerCase());
|
|
3282
3471
|
if (!targetId || targetId === d.id) continue;
|
|
3283
3472
|
const key = `${d.id}->${targetId}`;
|
|
3284
3473
|
if (seen.has(key)) continue;
|
|
@@ -3387,13 +3576,124 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3387
3576
|
}
|
|
3388
3577
|
|
|
3389
3578
|
// src/nodes/query-routes.ts
|
|
3390
|
-
var queryRoutes = new
|
|
3579
|
+
var queryRoutes = new Hono7();
|
|
3580
|
+
queryRoutes.get("/changes", async (c) => {
|
|
3581
|
+
const nestId = c.req.param("nestId");
|
|
3582
|
+
const userId = c.get("userId");
|
|
3583
|
+
const sinceRaw = c.req.query("since");
|
|
3584
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : NaN;
|
|
3585
|
+
if (!sinceRaw || Number.isNaN(sinceMs)) {
|
|
3586
|
+
throw new ValidationError(
|
|
3587
|
+
"since must be an ISO-8601 date, e.g. ?since=2026-07-01T00:00:00Z"
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
const includeContent = ["1", "true"].includes(
|
|
3591
|
+
c.req.query("include_content") || ""
|
|
3592
|
+
);
|
|
3593
|
+
const all = await listNodesForCaller(nestId, userId);
|
|
3594
|
+
const db = getDb();
|
|
3595
|
+
const publicReader = await isPublicReader(nestId, userId);
|
|
3596
|
+
const approvedRows = await db.all(
|
|
3597
|
+
"SELECT node_id, approved_at FROM approved_versions WHERE nest_id = ?",
|
|
3598
|
+
[nestId]
|
|
3599
|
+
);
|
|
3600
|
+
const parseDbTs = (t) => Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`);
|
|
3601
|
+
const approvedAtMsByNode = new Map(
|
|
3602
|
+
approvedRows.map((r) => [r.node_id, parseDbTs(r.approved_at)])
|
|
3603
|
+
);
|
|
3604
|
+
const publishedSince = new Set(
|
|
3605
|
+
approvedRows.filter((r) => parseDbTs(r.approved_at) > sinceMs).map((r) => r.node_id)
|
|
3606
|
+
);
|
|
3607
|
+
const summarize = (n, updatedOverride) => ({
|
|
3608
|
+
id: n.id,
|
|
3609
|
+
title: n.title,
|
|
3610
|
+
type: n.type,
|
|
3611
|
+
status: n.status,
|
|
3612
|
+
tags: n.tags,
|
|
3613
|
+
created_at: n.created_at,
|
|
3614
|
+
updated_at: updatedOverride ?? n.updated_at,
|
|
3615
|
+
...includeContent ? { content: n.content } : {}
|
|
3616
|
+
});
|
|
3617
|
+
const created = [];
|
|
3618
|
+
const updated = [];
|
|
3619
|
+
for (const n of all) {
|
|
3620
|
+
if (publicReader) {
|
|
3621
|
+
const approvedMs = approvedAtMsByNode.get(n.id);
|
|
3622
|
+
if (approvedMs === void 0) continue;
|
|
3623
|
+
const approvedIso = new Date(approvedMs).toISOString();
|
|
3624
|
+
const createdAt2 = Date.parse(n.created_at || "");
|
|
3625
|
+
if (!Number.isNaN(createdAt2) && createdAt2 > sinceMs) {
|
|
3626
|
+
created.push(summarize(n, approvedIso));
|
|
3627
|
+
} else if (approvedMs > sinceMs) {
|
|
3628
|
+
updated.push(summarize(n, approvedIso));
|
|
3629
|
+
}
|
|
3630
|
+
continue;
|
|
3631
|
+
}
|
|
3632
|
+
const createdAt = Date.parse(n.created_at || "");
|
|
3633
|
+
const updatedAt = Date.parse(n.updated_at || "");
|
|
3634
|
+
if (!Number.isNaN(createdAt) && createdAt > sinceMs) created.push(summarize(n));
|
|
3635
|
+
else if (!Number.isNaN(updatedAt) && updatedAt > sinceMs) updated.push(summarize(n));
|
|
3636
|
+
else if (publishedSince.has(n.id)) updated.push(summarize(n));
|
|
3637
|
+
}
|
|
3638
|
+
const canSeeDeleted = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3639
|
+
let deleted = null;
|
|
3640
|
+
if (canSeeDeleted) {
|
|
3641
|
+
const rows = await db.all(
|
|
3642
|
+
"SELECT node_id, deleted_by, deleted_at FROM node_deletions WHERE nest_id = ? AND deleted_at > ?",
|
|
3643
|
+
[nestId, new Date(sinceMs).toISOString()]
|
|
3644
|
+
);
|
|
3645
|
+
const liveIds = new Set(all.map((n) => n.id));
|
|
3646
|
+
deleted = rows.filter((r) => !liveIds.has(r.node_id)).map((r) => ({ id: r.node_id, deleted_by: r.deleted_by, deleted_at: r.deleted_at }));
|
|
3647
|
+
}
|
|
3648
|
+
return c.json({
|
|
3649
|
+
since: new Date(sinceMs).toISOString(),
|
|
3650
|
+
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3651
|
+
created,
|
|
3652
|
+
updated,
|
|
3653
|
+
deleted
|
|
3654
|
+
});
|
|
3655
|
+
});
|
|
3656
|
+
queryRoutes.get("/runnables", async (c) => {
|
|
3657
|
+
const nestId = c.req.param("nestId");
|
|
3658
|
+
const userId = c.get("userId");
|
|
3659
|
+
const all = await listNodesForCaller(nestId, userId, {
|
|
3660
|
+
type: RUNNABLE_NODE_TYPES
|
|
3661
|
+
});
|
|
3662
|
+
const runnables = all.map((n) => ({
|
|
3663
|
+
id: n.id,
|
|
3664
|
+
title: n.title,
|
|
3665
|
+
type: n.type,
|
|
3666
|
+
schedule: n.schedule ?? null,
|
|
3667
|
+
status: n.status,
|
|
3668
|
+
tags: n.tags,
|
|
3669
|
+
updated_at: n.updated_at,
|
|
3670
|
+
content: n.content
|
|
3671
|
+
}));
|
|
3672
|
+
return c.json({ count: runnables.length, runnables });
|
|
3673
|
+
});
|
|
3391
3674
|
queryRoutes.get("/graph", async (c) => {
|
|
3392
3675
|
const nestId = c.req.param("nestId");
|
|
3393
3676
|
const userId = c.get("userId");
|
|
3394
3677
|
const userEmail = await resolveCallerEmail(userId);
|
|
3395
3678
|
const canSeeIdentities = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3396
3679
|
const graph = await buildNestGraph(nestId, userId, userEmail, canSeeIdentities);
|
|
3680
|
+
if (config.FEATURE_WORKFLOW_PLANE) {
|
|
3681
|
+
const rows = await getDb().all(
|
|
3682
|
+
`SELECT e.from_node, e.to_node, e.condition_mode, t.name as type, t.color, t.is_flow
|
|
3683
|
+
FROM edges e JOIN edge_types t ON t.id = e.type_id
|
|
3684
|
+
WHERE e.nest_id = ?`,
|
|
3685
|
+
[nestId]
|
|
3686
|
+
);
|
|
3687
|
+
const visible = new Set(graph.nodes.map((n) => n.id));
|
|
3688
|
+
graph.typedEdges = rows.filter((r) => visible.has(r.from_node) && visible.has(r.to_node)).map((r) => ({
|
|
3689
|
+
source: r.from_node,
|
|
3690
|
+
target: r.to_node,
|
|
3691
|
+
type: r.type,
|
|
3692
|
+
color: r.color,
|
|
3693
|
+
is_flow: !!r.is_flow,
|
|
3694
|
+
condition_mode: r.condition_mode
|
|
3695
|
+
}));
|
|
3696
|
+
}
|
|
3397
3697
|
return c.json(graph);
|
|
3398
3698
|
});
|
|
3399
3699
|
function approxTokens(text) {
|
|
@@ -3775,89 +4075,1262 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
3775
4075
|
});
|
|
3776
4076
|
|
|
3777
4077
|
// src/mcp/routes.ts
|
|
3778
|
-
import { Hono as
|
|
4078
|
+
import { Hono as Hono10 } from "hono";
|
|
3779
4079
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3780
4080
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3781
4081
|
|
|
3782
|
-
// src/
|
|
3783
|
-
var
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
4082
|
+
// src/telemetry/trace-log.ts
|
|
4083
|
+
var RETENTION_DAYS = 14;
|
|
4084
|
+
var PRUNE_EVERY = 500;
|
|
4085
|
+
var insertsSincePrune = 0;
|
|
4086
|
+
async function logTraceEvent(e) {
|
|
4087
|
+
try {
|
|
4088
|
+
const db = getDb();
|
|
4089
|
+
await db.run(
|
|
4090
|
+
`INSERT INTO api_events
|
|
4091
|
+
(ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
|
|
4092
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4093
|
+
[
|
|
4094
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
4095
|
+
e.kind,
|
|
4096
|
+
e.method ?? null,
|
|
4097
|
+
e.path ?? null,
|
|
4098
|
+
e.tool ?? null,
|
|
4099
|
+
e.nestId ?? null,
|
|
4100
|
+
e.userId ?? null,
|
|
4101
|
+
e.userEmail ?? null,
|
|
4102
|
+
e.status ?? null,
|
|
4103
|
+
e.durationMs ?? null
|
|
4104
|
+
]
|
|
4105
|
+
);
|
|
4106
|
+
if (++insertsSincePrune >= PRUNE_EVERY) {
|
|
4107
|
+
insertsSincePrune = 0;
|
|
4108
|
+
const cutoff = new Date(
|
|
4109
|
+
Date.now() - RETENTION_DAYS * 864e5
|
|
4110
|
+
).toISOString();
|
|
4111
|
+
await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
|
|
3810
4112
|
}
|
|
3811
|
-
}
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
4113
|
+
} catch {
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
async function listTraceEvents(filters = {}) {
|
|
4117
|
+
const db = getDb();
|
|
4118
|
+
const limit = Number.isFinite(filters.limit) ? Math.min(Math.max(filters.limit, 1), 1e3) : 25;
|
|
4119
|
+
const offset = Number.isFinite(filters.offset) ? Math.max(filters.offset, 0) : 0;
|
|
4120
|
+
const from = "FROM api_events e LEFT JOIN users u ON u.id = e.user_id";
|
|
4121
|
+
const where = [];
|
|
4122
|
+
const args = [];
|
|
4123
|
+
if (filters.kind) {
|
|
4124
|
+
where.push("e.kind = ?");
|
|
4125
|
+
args.push(filters.kind);
|
|
4126
|
+
}
|
|
4127
|
+
if (filters.nestId) {
|
|
4128
|
+
where.push("e.nest_id = ?");
|
|
4129
|
+
args.push(filters.nestId);
|
|
4130
|
+
}
|
|
4131
|
+
if (filters.user) {
|
|
4132
|
+
where.push("(COALESCE(e.user_email, u.email) LIKE ? OR e.user_id = ?)");
|
|
4133
|
+
args.push(`%${filters.user}%`, filters.user);
|
|
4134
|
+
}
|
|
4135
|
+
const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
4136
|
+
try {
|
|
4137
|
+
const totalRow = await db.get(
|
|
4138
|
+
`SELECT COUNT(*) AS n ${from}${whereSql}`,
|
|
4139
|
+
args
|
|
4140
|
+
);
|
|
4141
|
+
const total = Number(totalRow?.n ?? 0);
|
|
4142
|
+
const events = await db.all(
|
|
4143
|
+
`SELECT e.*, COALESCE(e.user_email, u.email) AS caller
|
|
4144
|
+
${from}${whereSql}
|
|
4145
|
+
ORDER BY e.id DESC
|
|
4146
|
+
LIMIT ? OFFSET ?`,
|
|
4147
|
+
[...args, limit, offset]
|
|
4148
|
+
);
|
|
4149
|
+
return { events, total };
|
|
4150
|
+
} catch {
|
|
4151
|
+
return { events: [], total: 0 };
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
// src/mcp/workflow-tools.ts
|
|
4156
|
+
import { v4 as uuid8 } from "uuid";
|
|
4157
|
+
|
|
4158
|
+
// src/shared/node-id.ts
|
|
4159
|
+
function assertSafeNodeId(rawId) {
|
|
4160
|
+
let id = rawId;
|
|
4161
|
+
try {
|
|
4162
|
+
id = decodeURIComponent(rawId);
|
|
4163
|
+
} catch {
|
|
4164
|
+
}
|
|
4165
|
+
if (!id || id.length > 512) {
|
|
4166
|
+
throw new ValidationError("invalid node id");
|
|
4167
|
+
}
|
|
4168
|
+
if (id.includes("\0") || id.includes("\\")) {
|
|
4169
|
+
throw new ValidationError("invalid node id");
|
|
4170
|
+
}
|
|
4171
|
+
if (id.startsWith("/")) {
|
|
4172
|
+
throw new ValidationError("invalid node id (absolute path)");
|
|
4173
|
+
}
|
|
4174
|
+
for (const seg of id.split("/")) {
|
|
4175
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
4176
|
+
throw new ValidationError("invalid node id (path traversal)");
|
|
3825
4177
|
}
|
|
3826
|
-
}
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4178
|
+
}
|
|
4179
|
+
return id;
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
// src/workflow/run-service.ts
|
|
4183
|
+
import { v4 as uuid5 } from "uuid";
|
|
4184
|
+
|
|
4185
|
+
// src/shared/json.ts
|
|
4186
|
+
var safeJson = (s) => {
|
|
4187
|
+
if (!s) return null;
|
|
4188
|
+
try {
|
|
4189
|
+
return JSON.parse(s);
|
|
4190
|
+
} catch {
|
|
4191
|
+
return null;
|
|
4192
|
+
}
|
|
4193
|
+
};
|
|
4194
|
+
|
|
4195
|
+
// src/workflow/run-service.ts
|
|
4196
|
+
var RUNNABLE_TYPES = ["agent", "skill"];
|
|
4197
|
+
async function flowSubgraph(nestId, agentNode) {
|
|
4198
|
+
const db = getDb();
|
|
4199
|
+
const rows = await db.all(
|
|
4200
|
+
`SELECT e.id, e.from_node, e.to_node, e.condition_mode, e.condition, e.metadata,
|
|
4201
|
+
t.name as type_name, t.color as type_color
|
|
4202
|
+
FROM edges e JOIN edge_types t ON t.id = e.type_id
|
|
4203
|
+
WHERE e.nest_id = ? AND t.is_flow = 1`,
|
|
4204
|
+
[nestId]
|
|
4205
|
+
);
|
|
4206
|
+
const byFrom = /* @__PURE__ */ new Map();
|
|
4207
|
+
for (const r of rows) {
|
|
4208
|
+
if (!byFrom.has(r.from_node)) byFrom.set(r.from_node, []);
|
|
4209
|
+
byFrom.get(r.from_node).push(r);
|
|
4210
|
+
}
|
|
4211
|
+
const seen = /* @__PURE__ */ new Set([agentNode]);
|
|
4212
|
+
const queue = [agentNode];
|
|
4213
|
+
const edges = [];
|
|
4214
|
+
while (queue.length) {
|
|
4215
|
+
const cur = queue.shift();
|
|
4216
|
+
for (const e of byFrom.get(cur) ?? []) {
|
|
4217
|
+
edges.push(e);
|
|
4218
|
+
if (!seen.has(e.to_node)) {
|
|
4219
|
+
seen.add(e.to_node);
|
|
4220
|
+
queue.push(e.to_node);
|
|
3835
4221
|
}
|
|
3836
4222
|
}
|
|
3837
|
-
}
|
|
3838
|
-
{
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
4223
|
+
}
|
|
4224
|
+
return edges.map((e) => ({
|
|
4225
|
+
id: e.id,
|
|
4226
|
+
from_node: e.from_node,
|
|
4227
|
+
to_node: e.to_node,
|
|
4228
|
+
type: { name: e.type_name, color: e.type_color },
|
|
4229
|
+
condition_mode: e.condition_mode,
|
|
4230
|
+
condition: e.condition_mode === "structured" ? safeJson(e.condition) : e.condition,
|
|
4231
|
+
metadata: safeJson(e.metadata)
|
|
4232
|
+
}));
|
|
4233
|
+
}
|
|
4234
|
+
async function referencedDefinitions(nestId, edges) {
|
|
4235
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4236
|
+
for (const e of edges) {
|
|
4237
|
+
if (e.condition_mode === "structured" && e.condition && typeof e.condition === "object") {
|
|
4238
|
+
const t = e.condition.term;
|
|
4239
|
+
if (typeof t === "string" && t.trim()) terms.add(t.trim());
|
|
3848
4240
|
}
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
4241
|
+
}
|
|
4242
|
+
if (!terms.size) return [];
|
|
4243
|
+
try {
|
|
4244
|
+
const db = getDb();
|
|
4245
|
+
const list = [...terms];
|
|
4246
|
+
const placeholders = list.map(() => "LOWER(?)").join(", ");
|
|
4247
|
+
const rows = await db.all(
|
|
4248
|
+
`SELECT term, definition, linked_tag FROM definitions
|
|
4249
|
+
WHERE nest_id = ? AND LOWER(term) IN (${placeholders})`,
|
|
4250
|
+
[nestId, ...list]
|
|
4251
|
+
);
|
|
4252
|
+
return rows;
|
|
4253
|
+
} catch {
|
|
4254
|
+
return [];
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
async function resolveParentRun(nestId, parentRunId, childAgent) {
|
|
4258
|
+
if (!config.FEATURE_SUBAGENT_RUNS) {
|
|
4259
|
+
throw new ValidationError(
|
|
4260
|
+
"Sub-agent runs are not enabled on this server (FEATURE_SUBAGENT_RUNS)."
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
const db = getDb();
|
|
4264
|
+
let cursor = parentRunId;
|
|
4265
|
+
let parentDepth = -1;
|
|
4266
|
+
for (let i = 0; i <= config.SUBAGENT_MAX_DEPTH + 1; i++) {
|
|
4267
|
+
if (!cursor) break;
|
|
4268
|
+
const row = await db.get(
|
|
4269
|
+
"SELECT id, nest_id, agent_node, status, depth, parent_run_id FROM runs WHERE id = ?",
|
|
4270
|
+
[cursor]
|
|
4271
|
+
);
|
|
4272
|
+
if (!row || row.nest_id !== nestId) {
|
|
4273
|
+
throw new NotFoundError(`Parent run not found: ${cursor}`);
|
|
4274
|
+
}
|
|
4275
|
+
if (cursor === parentRunId && row.status !== "running") {
|
|
4276
|
+
throw new ConflictError(
|
|
4277
|
+
`Parent run is ${row.status} \u2014 cannot spawn a sub-agent under a closed run`
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
if (row.agent_node === childAgent) {
|
|
4281
|
+
throw new ConflictError(
|
|
4282
|
+
`re-entrancy: "${childAgent}" is already running in this call chain \u2014 sub-agent recursion must be acyclic`
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
if (cursor === parentRunId) parentDepth = row.depth;
|
|
4286
|
+
cursor = row.parent_run_id;
|
|
4287
|
+
}
|
|
4288
|
+
const depth = parentDepth + 1;
|
|
4289
|
+
if (depth > config.SUBAGENT_MAX_DEPTH) {
|
|
4290
|
+
throw new ForbiddenError(
|
|
4291
|
+
`sub-agent depth ${depth} exceeds the limit of ${config.SUBAGENT_MAX_DEPTH}`
|
|
4292
|
+
);
|
|
4293
|
+
}
|
|
4294
|
+
return { depth };
|
|
4295
|
+
}
|
|
4296
|
+
async function loadRun(runId) {
|
|
4297
|
+
return await getDb().get("SELECT * FROM runs WHERE id = ?", [runId]);
|
|
4298
|
+
}
|
|
4299
|
+
async function triggerRun(opts) {
|
|
4300
|
+
const { nestId, inputs, triggeredBy, storage } = opts;
|
|
4301
|
+
const agentNode = assertSafeNodeId(opts.agentNode);
|
|
4302
|
+
let doc;
|
|
4303
|
+
try {
|
|
4304
|
+
doc = await storage.readDocument(agentNode);
|
|
4305
|
+
} catch {
|
|
4306
|
+
throw new NotFoundError(`Agent node not found: ${agentNode}`);
|
|
4307
|
+
}
|
|
4308
|
+
const nodeType = doc.frontmatter.type || "document";
|
|
4309
|
+
if (!RUNNABLE_TYPES.includes(nodeType)) {
|
|
4310
|
+
throw new ValidationError(
|
|
4311
|
+
`"${agentNode}" is type "${nodeType}" \u2014 only agent/skill nodes are runnable`
|
|
4312
|
+
);
|
|
4313
|
+
}
|
|
4314
|
+
let parentRunId = null;
|
|
4315
|
+
let depth = 0;
|
|
4316
|
+
if (opts.parentRunId !== void 0 && opts.parentRunId !== null) {
|
|
4317
|
+
if (typeof opts.parentRunId !== "string") {
|
|
4318
|
+
throw new ValidationError("parent_run_id must be a string");
|
|
4319
|
+
}
|
|
4320
|
+
({ depth } = await resolveParentRun(nestId, opts.parentRunId, agentNode));
|
|
4321
|
+
parentRunId = opts.parentRunId;
|
|
4322
|
+
}
|
|
4323
|
+
const edges = await flowSubgraph(nestId, agentNode);
|
|
4324
|
+
const definitions = await referencedDefinitions(nestId, edges);
|
|
4325
|
+
const id = `run_${uuid5()}`;
|
|
4326
|
+
const db = getDb();
|
|
4327
|
+
await db.transaction(async (tx) => {
|
|
4328
|
+
if (parentRunId) {
|
|
4329
|
+
if (db.dialect === "postgres") {
|
|
4330
|
+
await tx.get("SELECT id FROM runs WHERE id = ? FOR UPDATE", [parentRunId]);
|
|
4331
|
+
}
|
|
4332
|
+
const kids = await tx.get(
|
|
4333
|
+
"SELECT COUNT(*) as c FROM runs WHERE parent_run_id = ?",
|
|
4334
|
+
[parentRunId]
|
|
4335
|
+
);
|
|
4336
|
+
if (Number(kids.c) >= config.SUBAGENT_MAX_CHILDREN) {
|
|
4337
|
+
throw new ForbiddenError(
|
|
4338
|
+
`parent run already has ${kids.c} children (limit ${config.SUBAGENT_MAX_CHILDREN})`
|
|
4339
|
+
);
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
await tx.run(
|
|
4343
|
+
`INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
|
|
4344
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
|
|
4345
|
+
[
|
|
4346
|
+
id,
|
|
4347
|
+
nestId,
|
|
4348
|
+
agentNode,
|
|
4349
|
+
triggeredBy,
|
|
4350
|
+
inputs === void 0 ? null : JSON.stringify(inputs),
|
|
4351
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
4352
|
+
parentRunId,
|
|
4353
|
+
depth
|
|
4354
|
+
]
|
|
4355
|
+
);
|
|
4356
|
+
});
|
|
4357
|
+
return {
|
|
4358
|
+
run_id: id,
|
|
4359
|
+
parent_run_id: parentRunId,
|
|
4360
|
+
depth,
|
|
4361
|
+
agent: {
|
|
4362
|
+
id: agentNode,
|
|
4363
|
+
title: String(doc.frontmatter.title ?? agentNode),
|
|
4364
|
+
type: nodeType,
|
|
4365
|
+
schedule: doc.frontmatter.metadata?.schedule ?? null,
|
|
4366
|
+
content: doc.body || ""
|
|
4367
|
+
},
|
|
4368
|
+
edges,
|
|
4369
|
+
definitions,
|
|
4370
|
+
trace_hint: {
|
|
4371
|
+
retrieval: "POST /context is deterministic \u2014 record selector+hops per read so the run replays to the identical read-set",
|
|
4372
|
+
steps: "POST /runs/{run_id}/steps per action; PATCH /runs/{run_id} to close"
|
|
4373
|
+
}
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
4376
|
+
var STEP_ACTIONS = ["read", "write", "branch", "notify", "external"];
|
|
4377
|
+
async function appendRunStep(runId, input) {
|
|
4378
|
+
if (typeof input.action !== "string" || !input.action.trim()) {
|
|
4379
|
+
throw new ValidationError("action is required (read|write|branch|notify|external)");
|
|
4380
|
+
}
|
|
4381
|
+
const action = input.action.trim();
|
|
4382
|
+
if (!STEP_ACTIONS.includes(action)) {
|
|
4383
|
+
throw new ValidationError(
|
|
4384
|
+
"action must be read | write | branch | notify | external"
|
|
4385
|
+
);
|
|
4386
|
+
}
|
|
4387
|
+
const nodeId = typeof input.node_id === "string" ? input.node_id : null;
|
|
4388
|
+
const edgeId = typeof input.edge_id === "string" ? input.edge_id : null;
|
|
4389
|
+
const detail = input.detail === void 0 ? null : JSON.stringify(input.detail);
|
|
4390
|
+
const db = getDb();
|
|
4391
|
+
let seq = 0;
|
|
4392
|
+
await db.transaction(async (tx) => {
|
|
4393
|
+
const cur = await tx.get("SELECT status FROM runs WHERE id = ?", [runId]);
|
|
4394
|
+
if (!cur) throw new NotFoundError("Run not found");
|
|
4395
|
+
if (cur.status !== "running") {
|
|
4396
|
+
throw new ConflictError(
|
|
4397
|
+
`Run is ${cur.status} \u2014 steps can't be appended`
|
|
4398
|
+
);
|
|
4399
|
+
}
|
|
4400
|
+
const last = await tx.get(
|
|
4401
|
+
"SELECT MAX(seq) as m FROM run_steps WHERE run_id = ?",
|
|
4402
|
+
[runId]
|
|
4403
|
+
);
|
|
4404
|
+
seq = (Number(last.m) || 0) + 1;
|
|
4405
|
+
if (seq > config.RUN_MAX_STEPS) {
|
|
4406
|
+
throw new ConflictError(
|
|
4407
|
+
`Run has reached the step limit of ${config.RUN_MAX_STEPS} \u2014 no more steps can be appended`
|
|
4408
|
+
);
|
|
4409
|
+
}
|
|
4410
|
+
await tx.run(
|
|
4411
|
+
`INSERT INTO run_steps (run_id, seq, node_id, edge_id, action, detail, at)
|
|
4412
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
4413
|
+
[runId, seq, nodeId, edgeId, action, detail, (/* @__PURE__ */ new Date()).toISOString()]
|
|
4414
|
+
);
|
|
4415
|
+
});
|
|
4416
|
+
return { seq };
|
|
4417
|
+
}
|
|
4418
|
+
var RUN_STATUSES = ["running", "succeeded", "failed", "cancelled"];
|
|
4419
|
+
async function closeRun(runId, input) {
|
|
4420
|
+
const status = input.status;
|
|
4421
|
+
if (typeof status !== "string" || !RUN_STATUSES.includes(status)) {
|
|
4422
|
+
throw new ValidationError("status must be running | succeeded | failed | cancelled");
|
|
4423
|
+
}
|
|
4424
|
+
const terminal = status !== "running";
|
|
4425
|
+
const res = await getDb().run(
|
|
4426
|
+
`UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ?
|
|
4427
|
+
WHERE id = ? AND status = 'running'`,
|
|
4428
|
+
[
|
|
4429
|
+
status,
|
|
4430
|
+
input.trace === void 0 ? null : JSON.stringify(input.trace),
|
|
4431
|
+
terminal ? (/* @__PURE__ */ new Date()).toISOString() : null,
|
|
4432
|
+
runId
|
|
4433
|
+
]
|
|
4434
|
+
);
|
|
4435
|
+
if (res.changes === 0) {
|
|
4436
|
+
const run = await loadRun(runId);
|
|
4437
|
+
if (!run) throw new NotFoundError("Run not found");
|
|
4438
|
+
throw new ConflictError("Run already terminal \u2014 status can't be changed");
|
|
4439
|
+
}
|
|
4440
|
+
return { status };
|
|
4441
|
+
}
|
|
4442
|
+
async function getRunTrace(run, opts) {
|
|
4443
|
+
const { canSeeDetail } = opts;
|
|
4444
|
+
const steps = await getDb().all(
|
|
4445
|
+
"SELECT seq, node_id, edge_id, action, detail, at FROM run_steps WHERE run_id = ? ORDER BY seq",
|
|
4446
|
+
[run.id]
|
|
4447
|
+
);
|
|
4448
|
+
return {
|
|
4449
|
+
run: {
|
|
4450
|
+
...run,
|
|
4451
|
+
inputs: canSeeDetail ? safeJson(run.inputs) : null,
|
|
4452
|
+
trace: canSeeDetail ? safeJson(run.trace) : null
|
|
4453
|
+
},
|
|
4454
|
+
steps: steps.map((s) => ({
|
|
4455
|
+
...s,
|
|
4456
|
+
detail: canSeeDetail ? safeJson(s.detail) : null
|
|
4457
|
+
}))
|
|
4458
|
+
};
|
|
4459
|
+
}
|
|
4460
|
+
async function listRuns(nestId, opts) {
|
|
4461
|
+
const db = getDb();
|
|
4462
|
+
let sql = "SELECT * FROM runs WHERE nest_id = ?";
|
|
4463
|
+
const args = [nestId];
|
|
4464
|
+
if (opts.agent) {
|
|
4465
|
+
sql += " AND agent_node = ?";
|
|
4466
|
+
args.push(opts.agent);
|
|
4467
|
+
}
|
|
4468
|
+
if (opts.status) {
|
|
4469
|
+
sql += " AND status = ?";
|
|
4470
|
+
args.push(opts.status);
|
|
4471
|
+
}
|
|
4472
|
+
sql += " ORDER BY started_at DESC LIMIT ?";
|
|
4473
|
+
args.push(Math.min(Math.max(Number(opts.limit) || 50, 1), 500));
|
|
4474
|
+
const rows = await db.all(sql, args);
|
|
4475
|
+
return {
|
|
4476
|
+
count: rows.length,
|
|
4477
|
+
runs: rows.map((r) => ({
|
|
4478
|
+
...r,
|
|
4479
|
+
inputs: opts.canSeeDetail ? safeJson(r.inputs) : null,
|
|
4480
|
+
trace: opts.canSeeDetail ? safeJson(r.trace) : null
|
|
4481
|
+
}))
|
|
4482
|
+
};
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
// src/workflow/edge-routes.ts
|
|
4486
|
+
import { Hono as Hono9 } from "hono";
|
|
4487
|
+
import { v4 as uuid7 } from "uuid";
|
|
4488
|
+
|
|
4489
|
+
// src/workflow/edge-type-routes.ts
|
|
4490
|
+
import { Hono as Hono8 } from "hono";
|
|
4491
|
+
import { v4 as uuid6 } from "uuid";
|
|
4492
|
+
var requireWorkflowPlane = async (c, next) => {
|
|
4493
|
+
if (!config.FEATURE_WORKFLOW_PLANE) {
|
|
4494
|
+
return c.json(
|
|
4495
|
+
{
|
|
4496
|
+
error: "The workflow plane is not enabled on this server. An admin can turn it on with FEATURE_WORKFLOW_PLANE=true."
|
|
4497
|
+
},
|
|
4498
|
+
404
|
|
4499
|
+
);
|
|
4500
|
+
}
|
|
4501
|
+
return next();
|
|
4502
|
+
};
|
|
4503
|
+
async function seedDefaultEdgeTypes(nestId) {
|
|
4504
|
+
const db = getDb();
|
|
4505
|
+
const existing = await db.get(
|
|
4506
|
+
"SELECT COUNT(*) as c FROM edge_types WHERE nest_id = ?",
|
|
4507
|
+
[nestId]
|
|
4508
|
+
);
|
|
4509
|
+
if (existing.c > 0) return;
|
|
4510
|
+
const owner = await db.get(
|
|
4511
|
+
"SELECT u.email FROM nests n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
|
|
4512
|
+
[nestId]
|
|
4513
|
+
);
|
|
4514
|
+
const createdBy = owner?.email ?? "admin@localhost";
|
|
4515
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4516
|
+
const defaults = [
|
|
4517
|
+
["next", "Unconditional flow: after the source completes, run the target.", 1, "#16a34a"],
|
|
4518
|
+
["on-success", "Follow only when the source step succeeded.", 1, "#16a34a"],
|
|
4519
|
+
["on-failure", "Follow only when the source step failed.", 1, "#ef4444"],
|
|
4520
|
+
["depends-on", "The source requires the target (ordering/lineage; not conditional flow).", 0, "#3b82f6"],
|
|
4521
|
+
["owned-by", "Ownership: the target person/team is accountable for the source node.", 0, "#64748b"]
|
|
4522
|
+
];
|
|
4523
|
+
for (const [name, description, isFlow, color] of defaults) {
|
|
4524
|
+
await db.run(
|
|
4525
|
+
`INSERT INTO edge_types
|
|
4526
|
+
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
4527
|
+
VALUES (?, ?, ?, ?, 'directed', ?, NULL, ?, ?, ?, ?)
|
|
4528
|
+
ON CONFLICT DO NOTHING`,
|
|
4529
|
+
[uuid6(), nestId, name, description, isFlow, color, createdBy, now, now]
|
|
4530
|
+
);
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
function parseConditionSchema(raw) {
|
|
4534
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
4535
|
+
const obj = typeof raw === "string" ? safeJson(raw) : raw;
|
|
4536
|
+
if (!obj || typeof obj !== "object" || !Array.isArray(obj.params) || !obj.params.every((p) => typeof p === "string")) {
|
|
4537
|
+
throw new ValidationError(
|
|
4538
|
+
'condition_schema must be {"params": [string\u2026], "mode_default"?: "structured"|"nl"|"open"}'
|
|
4539
|
+
);
|
|
4540
|
+
}
|
|
4541
|
+
const mode = obj.mode_default;
|
|
4542
|
+
if (mode !== void 0 && !["structured", "nl", "open"].includes(mode)) {
|
|
4543
|
+
throw new ValidationError(
|
|
4544
|
+
"condition_schema.mode_default must be structured | nl | open"
|
|
4545
|
+
);
|
|
4546
|
+
}
|
|
4547
|
+
return JSON.stringify({ params: obj.params, mode_default: mode ?? "structured" });
|
|
4548
|
+
}
|
|
4549
|
+
var edgeTypeToResponse = (r) => ({
|
|
4550
|
+
...r,
|
|
4551
|
+
is_flow: !!r.is_flow,
|
|
4552
|
+
condition_schema: r.condition_schema ? safeJson(r.condition_schema) : null
|
|
4553
|
+
});
|
|
4554
|
+
var edgeTypeRoutes = new Hono8();
|
|
4555
|
+
edgeTypeRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
4556
|
+
const nestId = c.req.param("nestId");
|
|
4557
|
+
await seedDefaultEdgeTypes(nestId);
|
|
4558
|
+
const rows = await getDb().all(
|
|
4559
|
+
"SELECT * FROM edge_types WHERE nest_id = ? ORDER BY LOWER(name)",
|
|
4560
|
+
[nestId]
|
|
4561
|
+
);
|
|
4562
|
+
return c.json({ count: rows.length, edge_types: rows.map(edgeTypeToResponse) });
|
|
4563
|
+
});
|
|
4564
|
+
edgeTypeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
4565
|
+
const nestId = c.req.param("nestId");
|
|
4566
|
+
const body = await c.req.json();
|
|
4567
|
+
if (typeof body.name !== "string" || !body.name.trim()) {
|
|
4568
|
+
throw new ValidationError("name is required");
|
|
4569
|
+
}
|
|
4570
|
+
if (typeof body.description !== "string" || !body.description.trim()) {
|
|
4571
|
+
throw new ValidationError("description (the articulation) is required");
|
|
4572
|
+
}
|
|
4573
|
+
const name = body.name.trim();
|
|
4574
|
+
if (name.length > 100 || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
|
|
4575
|
+
throw new ValidationError(
|
|
4576
|
+
"name must be alphanumeric-with-dashes, 100 chars max (e.g. escalates-when)"
|
|
4577
|
+
);
|
|
4578
|
+
}
|
|
4579
|
+
const direction = String(body.direction ?? "directed");
|
|
4580
|
+
if (!["directed", "undirected"].includes(direction)) {
|
|
4581
|
+
throw new ValidationError("direction must be directed | undirected");
|
|
4582
|
+
}
|
|
4583
|
+
const conditionSchema = parseConditionSchema(body.condition_schema);
|
|
4584
|
+
const isFlow = body.is_flow === true ? 1 : 0;
|
|
4585
|
+
let color = null;
|
|
4586
|
+
if (body.color !== void 0 && body.color !== null && body.color !== "") {
|
|
4587
|
+
if (typeof body.color !== "string" || !/^#[0-9a-f]{6}$/i.test(body.color)) {
|
|
4588
|
+
throw new ValidationError("color must be a 6-digit hex, e.g. #16a34a");
|
|
4589
|
+
}
|
|
4590
|
+
color = body.color;
|
|
4591
|
+
}
|
|
4592
|
+
const db = getDb();
|
|
4593
|
+
const definedBy = await resolveCallerEmail(c.get("userId"));
|
|
4594
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4595
|
+
await seedDefaultEdgeTypes(nestId);
|
|
4596
|
+
let created = false;
|
|
4597
|
+
await db.transaction(async (tx) => {
|
|
4598
|
+
const existing = await tx.get(
|
|
4599
|
+
"SELECT id FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
|
|
4600
|
+
[nestId, name]
|
|
4601
|
+
);
|
|
4602
|
+
if (existing) {
|
|
4603
|
+
await tx.run(
|
|
4604
|
+
`UPDATE edge_types SET name = ?, description = ?, direction = ?, is_flow = ?,
|
|
4605
|
+
condition_schema = ?, color = ?, updated_at = ? WHERE id = ?`,
|
|
4606
|
+
[name, body.description.trim(), direction, isFlow, conditionSchema, color, now, existing.id]
|
|
4607
|
+
);
|
|
4608
|
+
} else {
|
|
4609
|
+
created = true;
|
|
4610
|
+
await tx.run(
|
|
4611
|
+
`INSERT INTO edge_types
|
|
4612
|
+
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
4613
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4614
|
+
[uuid6(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
|
|
4615
|
+
);
|
|
4616
|
+
}
|
|
4617
|
+
});
|
|
4618
|
+
const row = await db.get(
|
|
4619
|
+
"SELECT * FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
|
|
4620
|
+
[nestId, name]
|
|
4621
|
+
);
|
|
4622
|
+
return c.json({ edge_type: edgeTypeToResponse(row) }, created ? 201 : 200);
|
|
4623
|
+
});
|
|
4624
|
+
edgeTypeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
|
|
4625
|
+
const nestId = c.req.param("nestId");
|
|
4626
|
+
const id = c.req.param("id");
|
|
4627
|
+
await getDb().transaction(async (tx) => {
|
|
4628
|
+
const row = await tx.get(
|
|
4629
|
+
"SELECT id FROM edge_types WHERE id = ? AND nest_id = ?",
|
|
4630
|
+
[id, nestId]
|
|
4631
|
+
);
|
|
4632
|
+
if (!row) throw new NotFoundError("Edge type not found");
|
|
4633
|
+
const inUse = await tx.get(
|
|
4634
|
+
"SELECT COUNT(*) as c FROM edges WHERE type_id = ?",
|
|
4635
|
+
[id]
|
|
4636
|
+
);
|
|
4637
|
+
if (inUse.c > 0) {
|
|
4638
|
+
throw new ValidationError(
|
|
4639
|
+
`Edge type is in use by ${inUse.c} edge${inUse.c === 1 ? "" : "s"} \u2014 delete those first.`
|
|
4640
|
+
);
|
|
4641
|
+
}
|
|
4642
|
+
await tx.run("DELETE FROM edge_types WHERE id = ?", [id]);
|
|
4643
|
+
});
|
|
4644
|
+
return c.json({ deleted: true });
|
|
4645
|
+
});
|
|
4646
|
+
|
|
4647
|
+
// src/workflow/edge-routes.ts
|
|
4648
|
+
function toResponse(e, t) {
|
|
4649
|
+
return {
|
|
4650
|
+
id: e.id,
|
|
4651
|
+
from_node: e.from_node,
|
|
4652
|
+
to_node: e.to_node,
|
|
4653
|
+
type: { id: t.id, name: t.name, color: t.color, is_flow: !!t.is_flow },
|
|
4654
|
+
condition_mode: e.condition_mode,
|
|
4655
|
+
condition: e.condition_mode === "structured" ? safeJson(e.condition) : e.condition,
|
|
4656
|
+
metadata: safeJson(e.metadata),
|
|
4657
|
+
created_by: e.created_by,
|
|
4658
|
+
created_at: e.created_at,
|
|
4659
|
+
updated_at: e.updated_at
|
|
4660
|
+
};
|
|
4661
|
+
}
|
|
4662
|
+
async function validateCondition(nestId, mode, condition) {
|
|
4663
|
+
if (mode === void 0 || mode === null) {
|
|
4664
|
+
if (condition !== void 0 && condition !== null) {
|
|
4665
|
+
throw new ValidationError("condition requires condition_mode");
|
|
4666
|
+
}
|
|
4667
|
+
return { mode: null, stored: null };
|
|
4668
|
+
}
|
|
4669
|
+
if (!["structured", "nl", "open"].includes(mode)) {
|
|
4670
|
+
throw new ValidationError("condition_mode must be structured | nl | open");
|
|
4671
|
+
}
|
|
4672
|
+
if (condition === void 0 || condition === null || condition === "") {
|
|
4673
|
+
throw new ValidationError(`condition_mode "${mode}" requires a condition`);
|
|
4674
|
+
}
|
|
4675
|
+
if (mode === "structured") {
|
|
4676
|
+
if (typeof condition !== "object" || Array.isArray(condition)) {
|
|
4677
|
+
throw new ValidationError(
|
|
4678
|
+
'structured condition must be an object, e.g. {"term":"CAC","op":">","value":500}'
|
|
4679
|
+
);
|
|
4680
|
+
}
|
|
4681
|
+
const c = condition;
|
|
4682
|
+
if (typeof c.term === "string" && c.term.trim()) {
|
|
4683
|
+
try {
|
|
4684
|
+
const row = await getDb().get(
|
|
4685
|
+
"SELECT id FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
|
|
4686
|
+
[nestId, c.term.trim()]
|
|
4687
|
+
);
|
|
4688
|
+
if (!row) {
|
|
4689
|
+
throw new ValidationError(
|
|
4690
|
+
`structured condition references undefined term "${c.term}" \u2014 define it in the glossary first`
|
|
4691
|
+
);
|
|
4692
|
+
}
|
|
4693
|
+
} catch (err) {
|
|
4694
|
+
if (err instanceof ValidationError) throw err;
|
|
4695
|
+
if (!/no such table/i.test(err?.message ?? "")) throw err;
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
return { mode, stored: JSON.stringify(condition) };
|
|
4699
|
+
}
|
|
4700
|
+
if (typeof condition !== "string") {
|
|
4701
|
+
throw new ValidationError(`condition for mode "${mode}" must be a string`);
|
|
4702
|
+
}
|
|
4703
|
+
return { mode, stored: condition };
|
|
4704
|
+
}
|
|
4705
|
+
async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
|
|
4706
|
+
if (fromNode === toNode) {
|
|
4707
|
+
throw new ConflictError(
|
|
4708
|
+
`cycle: ["${fromNode}","${toNode}"] \u2014 flow edges must form a DAG`
|
|
4709
|
+
);
|
|
4710
|
+
}
|
|
4711
|
+
const rows = await db.all(
|
|
4712
|
+
`SELECT e.from_node, e.to_node FROM edges e
|
|
4713
|
+
JOIN edge_types t ON t.id = e.type_id
|
|
4714
|
+
WHERE e.nest_id = ? AND t.is_flow = 1`,
|
|
4715
|
+
[nestId]
|
|
4716
|
+
);
|
|
4717
|
+
const adj = /* @__PURE__ */ new Map();
|
|
4718
|
+
for (const r of rows) {
|
|
4719
|
+
(adj.get(r.from_node) ?? adj.set(r.from_node, []).get(r.from_node)).push(
|
|
4720
|
+
r.to_node
|
|
4721
|
+
);
|
|
4722
|
+
}
|
|
4723
|
+
const parent = /* @__PURE__ */ new Map();
|
|
4724
|
+
const queue = [toNode];
|
|
4725
|
+
const seen = /* @__PURE__ */ new Set([toNode]);
|
|
4726
|
+
while (queue.length) {
|
|
4727
|
+
const cur = queue.shift();
|
|
4728
|
+
if (cur === fromNode) {
|
|
4729
|
+
const path = [fromNode];
|
|
4730
|
+
let p = fromNode;
|
|
4731
|
+
while (p !== toNode) {
|
|
4732
|
+
p = parent.get(p);
|
|
4733
|
+
path.push(p);
|
|
4734
|
+
}
|
|
4735
|
+
path.reverse().push(toNode);
|
|
4736
|
+
throw new ConflictError(
|
|
4737
|
+
`cycle: ${JSON.stringify(path)} \u2014 flow edges must form a DAG`
|
|
4738
|
+
);
|
|
4739
|
+
}
|
|
4740
|
+
for (const nb of adj.get(cur) ?? []) {
|
|
4741
|
+
if (!seen.has(nb)) {
|
|
4742
|
+
seen.add(nb);
|
|
4743
|
+
parent.set(nb, cur);
|
|
4744
|
+
queue.push(nb);
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
}
|
|
4749
|
+
var edgeRoutes = new Hono9();
|
|
4750
|
+
edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
4751
|
+
const nestId = c.req.param("nestId");
|
|
4752
|
+
const db = getDb();
|
|
4753
|
+
const typeFilter = c.req.query("type")?.trim();
|
|
4754
|
+
const nodeFilter = c.req.query("node")?.trim();
|
|
4755
|
+
let sql = `SELECT e.*, t.id as t_id, t.name as t_name, t.color as t_color, t.is_flow as t_is_flow
|
|
4756
|
+
FROM edges e JOIN edge_types t ON t.id = e.type_id
|
|
4757
|
+
WHERE e.nest_id = ?`;
|
|
4758
|
+
const args = [nestId];
|
|
4759
|
+
if (typeFilter) {
|
|
4760
|
+
sql += " AND (t.id = ? OR LOWER(t.name) = LOWER(?))";
|
|
4761
|
+
args.push(typeFilter, typeFilter);
|
|
4762
|
+
}
|
|
4763
|
+
if (nodeFilter) {
|
|
4764
|
+
sql += " AND (e.from_node = ? OR e.to_node = ?)";
|
|
4765
|
+
args.push(nodeFilter, nodeFilter);
|
|
4766
|
+
}
|
|
4767
|
+
sql += " ORDER BY e.created_at";
|
|
4768
|
+
const rows = await db.all(sql, args);
|
|
4769
|
+
const edges = rows.map(
|
|
4770
|
+
(r) => toResponse(r, {
|
|
4771
|
+
id: r.t_id,
|
|
4772
|
+
name: r.t_name,
|
|
4773
|
+
color: r.t_color,
|
|
4774
|
+
is_flow: r.t_is_flow
|
|
4775
|
+
})
|
|
4776
|
+
);
|
|
4777
|
+
return c.json({ count: edges.length, edges });
|
|
4778
|
+
});
|
|
4779
|
+
edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
4780
|
+
const nestId = c.req.param("nestId");
|
|
4781
|
+
const body = await c.req.json();
|
|
4782
|
+
const fromRaw = typeof body.from_node === "string" ? body.from_node.trim() : "";
|
|
4783
|
+
const toRaw = typeof body.to_node === "string" ? body.to_node.trim() : "";
|
|
4784
|
+
const typeRef = typeof body.type === "string" ? body.type.trim() : "";
|
|
4785
|
+
if (!fromRaw || !toRaw || !typeRef) {
|
|
4786
|
+
throw new ValidationError("from_node, to_node, and type are required");
|
|
4787
|
+
}
|
|
4788
|
+
const fromNode = assertSafeNodeId(fromRaw);
|
|
4789
|
+
const toNode = assertSafeNodeId(toRaw);
|
|
4790
|
+
const db = getDb();
|
|
4791
|
+
const createdBy = await resolveCallerEmail(c.get("userId"));
|
|
4792
|
+
await seedDefaultEdgeTypes(nestId);
|
|
4793
|
+
const type = await db.get(
|
|
4794
|
+
"SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
|
|
4795
|
+
[nestId, typeRef, typeRef]
|
|
4796
|
+
);
|
|
4797
|
+
if (!type) {
|
|
4798
|
+
throw new ValidationError(
|
|
4799
|
+
`unknown edge type "${typeRef}" \u2014 define it in the registry first`
|
|
4800
|
+
);
|
|
4801
|
+
}
|
|
4802
|
+
const { storage } = await engineCache.get(nestId);
|
|
4803
|
+
for (const nodeId of [fromNode, toNode]) {
|
|
4804
|
+
try {
|
|
4805
|
+
await storage.readDocument(nodeId);
|
|
4806
|
+
} catch {
|
|
4807
|
+
throw new ValidationError(`node not found: "${nodeId}"`);
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
const { mode, stored } = await validateCondition(
|
|
4811
|
+
nestId,
|
|
4812
|
+
body.condition_mode,
|
|
4813
|
+
body.condition
|
|
4814
|
+
);
|
|
4815
|
+
const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
|
|
4816
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4817
|
+
const id = uuid7();
|
|
4818
|
+
await db.transaction(async (tx) => {
|
|
4819
|
+
if (type.is_flow) {
|
|
4820
|
+
await assertNoFlowCycle(tx, nestId, fromNode, toNode);
|
|
4821
|
+
}
|
|
4822
|
+
await tx.run(
|
|
4823
|
+
`INSERT INTO edges
|
|
4824
|
+
(id, nest_id, from_node, to_node, type_id, condition_mode, condition, metadata, created_by, created_at, updated_at)
|
|
4825
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4826
|
+
[id, nestId, fromNode, toNode, type.id, mode, stored, metadata, createdBy, now, now]
|
|
4827
|
+
);
|
|
4828
|
+
});
|
|
4829
|
+
const row = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
4830
|
+
return c.json({ edge: toResponse(row, type) }, 201);
|
|
4831
|
+
});
|
|
4832
|
+
edgeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
|
|
4833
|
+
const nestId = c.req.param("nestId");
|
|
4834
|
+
const id = c.req.param("id");
|
|
4835
|
+
const db = getDb();
|
|
4836
|
+
const row = await db.get(
|
|
4837
|
+
"SELECT id FROM edges WHERE id = ? AND nest_id = ?",
|
|
4838
|
+
[id, nestId]
|
|
4839
|
+
);
|
|
4840
|
+
if (!row) throw new NotFoundError("Edge not found");
|
|
4841
|
+
await db.run("DELETE FROM edges WHERE id = ?", [id]);
|
|
4842
|
+
return c.json({ deleted: true });
|
|
4843
|
+
});
|
|
4844
|
+
|
|
4845
|
+
// src/mcp/workflow-tools.ts
|
|
4846
|
+
var WORKFLOW_TOOL_DEFINITIONS = [
|
|
4847
|
+
// ── Execute ──────────────────────────────────────────────────────────
|
|
4848
|
+
{
|
|
4849
|
+
name: "workflow_run",
|
|
4850
|
+
description: "Trigger a governed workflow run for an agent/skill node and get the executable bundle back (agent instructions, the reachable flow-edge subgraph, and referenced glossary definitions) plus a run_id. The server does not execute \u2014 YOU are the runner: walk the edges, then report progress with workflow_run_step and finish with workflow_run_close.",
|
|
4851
|
+
inputSchema: {
|
|
4852
|
+
type: "object",
|
|
4853
|
+
properties: {
|
|
4854
|
+
agent: { type: "string", description: "Node id of the agent/skill to run" },
|
|
4855
|
+
inputs: { type: "object", description: "Optional caller inputs recorded on the run" },
|
|
4856
|
+
parent_run_id: {
|
|
4857
|
+
type: "string",
|
|
4858
|
+
description: "Optional: spawn this as a sub-agent run under a still-running parent (FEATURE_SUBAGENT_RUNS). Bounded by depth/fan-out and guarded against re-entrancy."
|
|
4859
|
+
}
|
|
4860
|
+
},
|
|
4861
|
+
required: ["agent"]
|
|
4862
|
+
}
|
|
4863
|
+
},
|
|
4864
|
+
{
|
|
4865
|
+
name: "workflow_run_step",
|
|
4866
|
+
description: "Append one step to a run's trace after every action. seq is assigned server-side. For branch steps put the evaluated condition, result and (NL mode) rationale in detail. Fails if the run is already closed.",
|
|
4867
|
+
inputSchema: {
|
|
4868
|
+
type: "object",
|
|
4869
|
+
properties: {
|
|
4870
|
+
run_id: { type: "string" },
|
|
4871
|
+
action: { type: "string", description: "read | write | branch | notify | external" },
|
|
4872
|
+
node_id: { type: "string", description: "Node the step touched (optional)" },
|
|
4873
|
+
edge_id: { type: "string", description: "Edge the step followed (optional)" },
|
|
4874
|
+
detail: { type: "object", description: "Freeform payload: reads, condition results, rationale" }
|
|
4875
|
+
},
|
|
4876
|
+
required: ["run_id", "action"]
|
|
4877
|
+
}
|
|
4878
|
+
},
|
|
4879
|
+
{
|
|
4880
|
+
name: "workflow_run_close",
|
|
4881
|
+
description: "Close a run with a terminal status (succeeded | failed | cancelled) and an optional trace summary. Terminal is final \u2014 a second close returns a conflict.",
|
|
4882
|
+
inputSchema: {
|
|
4883
|
+
type: "object",
|
|
4884
|
+
properties: {
|
|
4885
|
+
run_id: { type: "string" },
|
|
4886
|
+
status: { type: "string", description: "succeeded | failed | cancelled (or running to update trace)" },
|
|
4887
|
+
trace: { type: "object", description: "Optional summary/totals recorded on the run" }
|
|
4888
|
+
},
|
|
4889
|
+
required: ["run_id", "status"]
|
|
4890
|
+
}
|
|
4891
|
+
},
|
|
4892
|
+
{
|
|
4893
|
+
name: "workflow_run_get",
|
|
4894
|
+
description: "Fetch one run's full trace (steps in order). Freeform payloads (inputs, trace, step detail) are redacted unless you have write access to the nest.",
|
|
4895
|
+
inputSchema: {
|
|
4896
|
+
type: "object",
|
|
4897
|
+
properties: { run_id: { type: "string" } },
|
|
4898
|
+
required: ["run_id"]
|
|
4899
|
+
}
|
|
4900
|
+
},
|
|
4901
|
+
{
|
|
4902
|
+
name: "workflow_runs_list",
|
|
4903
|
+
description: "List runs in this nest, most recent first. Filter by agent node id and/or status (running|succeeded|failed|cancelled).",
|
|
4904
|
+
inputSchema: {
|
|
4905
|
+
type: "object",
|
|
4906
|
+
properties: {
|
|
4907
|
+
agent: { type: "string" },
|
|
4908
|
+
status: { type: "string" },
|
|
4909
|
+
limit: { type: "number" }
|
|
4910
|
+
},
|
|
4911
|
+
required: []
|
|
4912
|
+
}
|
|
4913
|
+
},
|
|
4914
|
+
// ── Build: edges ─────────────────────────────────────────────────────
|
|
4915
|
+
{
|
|
4916
|
+
name: "workflow_edges_list",
|
|
4917
|
+
description: "List typed edges in this nest. Optional filters: type (edge-type name or id) and node (id appearing as either endpoint).",
|
|
4918
|
+
inputSchema: {
|
|
4919
|
+
type: "object",
|
|
4920
|
+
properties: { type: { type: "string" }, node: { type: "string" } },
|
|
4921
|
+
required: []
|
|
4922
|
+
}
|
|
4923
|
+
},
|
|
4924
|
+
{
|
|
4925
|
+
name: "workflow_edge_create",
|
|
4926
|
+
description: "Create a typed edge between two existing nodes. The edge type must already exist in the registry. Optional condition in mode structured (JSON predicate {term,op,value}; term must be a defined glossary term), nl (natural-language, judged at run time), or open. Flow-type edges must keep the graph a DAG \u2014 a cycle is rejected.",
|
|
4927
|
+
inputSchema: {
|
|
4928
|
+
type: "object",
|
|
4929
|
+
properties: {
|
|
4930
|
+
from_node: { type: "string" },
|
|
4931
|
+
to_node: { type: "string" },
|
|
4932
|
+
type: { type: "string", description: "Edge-type name or id" },
|
|
4933
|
+
condition_mode: { type: "string", description: "structured | nl | open" },
|
|
4934
|
+
condition: { type: "object", description: "Predicate object (structured) or string (nl/open)" },
|
|
4935
|
+
metadata: { type: "object" }
|
|
4936
|
+
},
|
|
4937
|
+
required: ["from_node", "to_node", "type"]
|
|
4938
|
+
}
|
|
4939
|
+
},
|
|
4940
|
+
{
|
|
4941
|
+
name: "workflow_edge_delete",
|
|
4942
|
+
description: "Delete an edge by id.",
|
|
4943
|
+
inputSchema: {
|
|
4944
|
+
type: "object",
|
|
4945
|
+
properties: { edge_id: { type: "string" } },
|
|
4946
|
+
required: ["edge_id"]
|
|
4947
|
+
}
|
|
4948
|
+
},
|
|
4949
|
+
// ── Build: edge types ────────────────────────────────────────────────
|
|
4950
|
+
{
|
|
4951
|
+
name: "workflow_edge_types_list",
|
|
4952
|
+
description: "List this nest's edge-type registry \u2014 the articulated vocabulary of relations. Seeds the five stock flow types on first use.",
|
|
4953
|
+
inputSchema: { type: "object", properties: {}, required: [] }
|
|
4954
|
+
},
|
|
4955
|
+
{
|
|
4956
|
+
name: "workflow_edge_type_upsert",
|
|
4957
|
+
description: "Create or update an edge type by name (case-insensitive). name is a slug (alphanumeric-with-dashes). Set is_flow true for control-flow relations (they get DAG-validated). condition_schema is an optional {params:[string\u2026], mode_default?} template.",
|
|
4958
|
+
inputSchema: {
|
|
4959
|
+
type: "object",
|
|
4960
|
+
properties: {
|
|
4961
|
+
name: { type: "string" },
|
|
4962
|
+
description: { type: "string" },
|
|
4963
|
+
direction: { type: "string", description: "directed | undirected (default directed)" },
|
|
4964
|
+
is_flow: { type: "boolean" },
|
|
4965
|
+
condition_schema: { type: "object" },
|
|
4966
|
+
color: { type: "string", description: "6-digit hex, e.g. #16a34a" }
|
|
4967
|
+
},
|
|
4968
|
+
required: ["name", "description"]
|
|
4969
|
+
}
|
|
4970
|
+
},
|
|
4971
|
+
{
|
|
4972
|
+
name: "workflow_edge_type_delete",
|
|
4973
|
+
description: "Delete an edge type by id. Refused while any edge still references it.",
|
|
4974
|
+
inputSchema: {
|
|
4975
|
+
type: "object",
|
|
4976
|
+
properties: { edge_type_id: { type: "string" } },
|
|
4977
|
+
required: ["edge_type_id"]
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
];
|
|
4981
|
+
var WORKFLOW_TOOL_NAMES = new Set(WORKFLOW_TOOL_DEFINITIONS.map((t) => t.name));
|
|
4982
|
+
var isWorkflowTool = (name) => WORKFLOW_TOOL_NAMES.has(name);
|
|
4983
|
+
async function nestPermission(ctx) {
|
|
4984
|
+
if (config.AUTH_MODE === "open") return "owner";
|
|
4985
|
+
return resolveNestPermission(ctx.nestId, ctx.userId);
|
|
4986
|
+
}
|
|
4987
|
+
function requireTier(perm, tier) {
|
|
4988
|
+
if (permissionLevel(perm) < permissionLevel(tier)) {
|
|
4989
|
+
throw new ForbiddenError(
|
|
4990
|
+
`This action needs ${tier} access to the nest.`
|
|
4991
|
+
);
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
async function loadNestRun(ctx, runId) {
|
|
4995
|
+
if (typeof runId !== "string" || !runId) {
|
|
4996
|
+
throw new ValidationError("run_id is required");
|
|
4997
|
+
}
|
|
4998
|
+
const run = await loadRun(runId);
|
|
4999
|
+
if (!run || run.nest_id !== ctx.nestId) throw new NotFoundError("Run not found");
|
|
5000
|
+
return run;
|
|
5001
|
+
}
|
|
5002
|
+
var json = (v) => JSON.stringify(v, null, 2);
|
|
5003
|
+
async function runWorkflowTool(toolName, args, ctx) {
|
|
5004
|
+
const perm = await nestPermission(ctx);
|
|
5005
|
+
switch (toolName) {
|
|
5006
|
+
// ── Execute ────────────────────────────────────────────────────────
|
|
5007
|
+
case "workflow_run": {
|
|
5008
|
+
requireTier(perm, "write");
|
|
5009
|
+
const triggeredBy = await resolveCallerEmail(ctx.userId);
|
|
5010
|
+
const bundle = await triggerRun({
|
|
5011
|
+
nestId: ctx.nestId,
|
|
5012
|
+
agentNode: String(args.agent ?? ""),
|
|
5013
|
+
inputs: args.inputs,
|
|
5014
|
+
triggeredBy,
|
|
5015
|
+
storage: ctx.storage,
|
|
5016
|
+
parentRunId: args.parent_run_id
|
|
5017
|
+
});
|
|
5018
|
+
return json(bundle);
|
|
5019
|
+
}
|
|
5020
|
+
case "workflow_run_step": {
|
|
5021
|
+
requireTier(perm, "write");
|
|
5022
|
+
const run = await loadNestRun(ctx, args.run_id);
|
|
5023
|
+
const { seq } = await appendRunStep(run.id, {
|
|
5024
|
+
action: args.action,
|
|
5025
|
+
node_id: args.node_id,
|
|
5026
|
+
edge_id: args.edge_id,
|
|
5027
|
+
detail: args.detail
|
|
5028
|
+
});
|
|
5029
|
+
return json({ ok: true, run_id: run.id, seq });
|
|
5030
|
+
}
|
|
5031
|
+
case "workflow_run_close": {
|
|
5032
|
+
requireTier(perm, "write");
|
|
5033
|
+
const run = await loadNestRun(ctx, args.run_id);
|
|
5034
|
+
const { status } = await closeRun(run.id, {
|
|
5035
|
+
status: args.status,
|
|
5036
|
+
trace: args.trace
|
|
5037
|
+
});
|
|
5038
|
+
return json({ ok: true, run_id: run.id, status });
|
|
5039
|
+
}
|
|
5040
|
+
case "workflow_run_get": {
|
|
5041
|
+
requireTier(perm, "read");
|
|
5042
|
+
const run = await loadNestRun(ctx, args.run_id);
|
|
5043
|
+
const canSeeDetail = permissionLevel(perm) >= permissionLevel("write");
|
|
5044
|
+
return json(await getRunTrace(run, { canSeeDetail }));
|
|
5045
|
+
}
|
|
5046
|
+
case "workflow_runs_list": {
|
|
5047
|
+
requireTier(perm, "read");
|
|
5048
|
+
const canSeeDetail = permissionLevel(perm) >= permissionLevel("write");
|
|
5049
|
+
return json(
|
|
5050
|
+
await listRuns(ctx.nestId, {
|
|
5051
|
+
agent: typeof args.agent === "string" ? args.agent : void 0,
|
|
5052
|
+
status: typeof args.status === "string" ? args.status : void 0,
|
|
5053
|
+
limit: typeof args.limit === "number" ? args.limit : void 0,
|
|
5054
|
+
canSeeDetail
|
|
5055
|
+
})
|
|
5056
|
+
);
|
|
5057
|
+
}
|
|
5058
|
+
// ── Build: edges ───────────────────────────────────────────────────
|
|
5059
|
+
case "workflow_edges_list": {
|
|
5060
|
+
requireTier(perm, "read");
|
|
5061
|
+
return json(await listEdges(ctx.nestId, args.type, args.node));
|
|
5062
|
+
}
|
|
5063
|
+
case "workflow_edge_create": {
|
|
5064
|
+
requireTier(perm, "write");
|
|
5065
|
+
return json(await createEdge(ctx, args));
|
|
5066
|
+
}
|
|
5067
|
+
case "workflow_edge_delete": {
|
|
5068
|
+
requireTier(perm, "write");
|
|
5069
|
+
const id = String(args.edge_id ?? "");
|
|
5070
|
+
const row = await getDb().get(
|
|
5071
|
+
"SELECT id FROM edges WHERE id = ? AND nest_id = ?",
|
|
5072
|
+
[id, ctx.nestId]
|
|
5073
|
+
);
|
|
5074
|
+
if (!row) throw new NotFoundError("Edge not found");
|
|
5075
|
+
await getDb().run("DELETE FROM edges WHERE id = ?", [id]);
|
|
5076
|
+
return json({ deleted: true, edge_id: id });
|
|
5077
|
+
}
|
|
5078
|
+
// ── Build: edge types ──────────────────────────────────────────────
|
|
5079
|
+
case "workflow_edge_types_list": {
|
|
5080
|
+
requireTier(perm, "read");
|
|
5081
|
+
await seedDefaultEdgeTypes(ctx.nestId);
|
|
5082
|
+
const rows = await getDb().all(
|
|
5083
|
+
"SELECT * FROM edge_types WHERE nest_id = ? ORDER BY LOWER(name)",
|
|
5084
|
+
[ctx.nestId]
|
|
5085
|
+
);
|
|
5086
|
+
return json({ count: rows.length, edge_types: rows.map(edgeTypeToResponse) });
|
|
5087
|
+
}
|
|
5088
|
+
case "workflow_edge_type_upsert": {
|
|
5089
|
+
requireTier(perm, "write");
|
|
5090
|
+
return json(await upsertEdgeType(ctx, args));
|
|
5091
|
+
}
|
|
5092
|
+
case "workflow_edge_type_delete": {
|
|
5093
|
+
requireTier(perm, "write");
|
|
5094
|
+
return json(await deleteEdgeType(ctx.nestId, String(args.edge_type_id ?? "")));
|
|
5095
|
+
}
|
|
5096
|
+
default:
|
|
5097
|
+
return json({ error: `Unknown workflow tool: ${toolName}` });
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
async function listEdges(nestId, type, node) {
|
|
5101
|
+
const db = getDb();
|
|
5102
|
+
let sql = `SELECT e.*, t.id as t_id, t.name as t_name, t.color as t_color, t.is_flow as t_is_flow
|
|
5103
|
+
FROM edges e JOIN edge_types t ON t.id = e.type_id
|
|
5104
|
+
WHERE e.nest_id = ?`;
|
|
5105
|
+
const args = [nestId];
|
|
5106
|
+
if (typeof type === "string" && type.trim()) {
|
|
5107
|
+
sql += " AND (t.id = ? OR LOWER(t.name) = LOWER(?))";
|
|
5108
|
+
args.push(type.trim(), type.trim());
|
|
5109
|
+
}
|
|
5110
|
+
if (typeof node === "string" && node.trim()) {
|
|
5111
|
+
sql += " AND (e.from_node = ? OR e.to_node = ?)";
|
|
5112
|
+
args.push(node.trim(), node.trim());
|
|
5113
|
+
}
|
|
5114
|
+
sql += " ORDER BY e.created_at";
|
|
5115
|
+
const rows = await db.all(sql, args);
|
|
5116
|
+
const edges = rows.map(
|
|
5117
|
+
(r) => toResponse(r, {
|
|
5118
|
+
id: r.t_id,
|
|
5119
|
+
name: r.t_name,
|
|
5120
|
+
color: r.t_color,
|
|
5121
|
+
is_flow: r.t_is_flow
|
|
5122
|
+
})
|
|
5123
|
+
);
|
|
5124
|
+
return { count: edges.length, edges };
|
|
5125
|
+
}
|
|
5126
|
+
async function createEdge(ctx, args) {
|
|
5127
|
+
const fromNode = typeof args.from_node === "string" ? args.from_node.trim() : "";
|
|
5128
|
+
const toNode = typeof args.to_node === "string" ? args.to_node.trim() : "";
|
|
5129
|
+
const typeRef = typeof args.type === "string" ? args.type.trim() : "";
|
|
5130
|
+
if (!fromNode || !toNode || !typeRef) {
|
|
5131
|
+
throw new ValidationError("from_node, to_node, and type are required");
|
|
5132
|
+
}
|
|
5133
|
+
assertSafeNodeId(fromNode);
|
|
5134
|
+
assertSafeNodeId(toNode);
|
|
5135
|
+
const db = getDb();
|
|
5136
|
+
const createdBy = await resolveCallerEmail(ctx.userId);
|
|
5137
|
+
await seedDefaultEdgeTypes(ctx.nestId);
|
|
5138
|
+
const type = await db.get(
|
|
5139
|
+
"SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
|
|
5140
|
+
[ctx.nestId, typeRef, typeRef]
|
|
5141
|
+
);
|
|
5142
|
+
if (!type) {
|
|
5143
|
+
throw new ValidationError(
|
|
5144
|
+
`unknown edge type "${typeRef}" \u2014 create it first with workflow_edge_type_upsert`
|
|
5145
|
+
);
|
|
5146
|
+
}
|
|
5147
|
+
for (const nodeId of [fromNode, toNode]) {
|
|
5148
|
+
try {
|
|
5149
|
+
await ctx.storage.readDocument(nodeId);
|
|
5150
|
+
} catch {
|
|
5151
|
+
throw new ValidationError(`node not found: "${nodeId}"`);
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
const { mode, stored } = await validateCondition(
|
|
5155
|
+
ctx.nestId,
|
|
5156
|
+
args.condition_mode,
|
|
5157
|
+
args.condition
|
|
5158
|
+
);
|
|
5159
|
+
const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
|
|
5160
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5161
|
+
const id = uuid8();
|
|
5162
|
+
await db.transaction(async (tx) => {
|
|
5163
|
+
if (type.is_flow) {
|
|
5164
|
+
await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
|
|
5165
|
+
}
|
|
5166
|
+
await tx.run(
|
|
5167
|
+
`INSERT INTO edges
|
|
5168
|
+
(id, nest_id, from_node, to_node, type_id, condition_mode, condition, metadata, created_by, created_at, updated_at)
|
|
5169
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
5170
|
+
[id, ctx.nestId, fromNode, toNode, type.id, mode, stored, metadata, createdBy, now, now]
|
|
5171
|
+
);
|
|
5172
|
+
});
|
|
5173
|
+
const row = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
5174
|
+
return { edge: toResponse(row, type) };
|
|
5175
|
+
}
|
|
5176
|
+
async function upsertEdgeType(ctx, args) {
|
|
5177
|
+
if (typeof args.name !== "string" || !args.name.trim()) {
|
|
5178
|
+
throw new ValidationError("name is required");
|
|
5179
|
+
}
|
|
5180
|
+
if (typeof args.description !== "string" || !args.description.trim()) {
|
|
5181
|
+
throw new ValidationError("description (the articulation) is required");
|
|
5182
|
+
}
|
|
5183
|
+
const name = args.name.trim();
|
|
5184
|
+
if (name.length > 100 || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
|
|
5185
|
+
throw new ValidationError(
|
|
5186
|
+
"name must be alphanumeric-with-dashes, 100 chars max (e.g. escalates-when)"
|
|
5187
|
+
);
|
|
5188
|
+
}
|
|
5189
|
+
const direction = String(args.direction ?? "directed");
|
|
5190
|
+
if (!["directed", "undirected"].includes(direction)) {
|
|
5191
|
+
throw new ValidationError("direction must be directed | undirected");
|
|
5192
|
+
}
|
|
5193
|
+
const conditionSchema = parseConditionSchema(args.condition_schema);
|
|
5194
|
+
const isFlow = args.is_flow === true ? 1 : 0;
|
|
5195
|
+
let color = null;
|
|
5196
|
+
if (args.color !== void 0 && args.color !== null && args.color !== "") {
|
|
5197
|
+
if (typeof args.color !== "string" || !/^#[0-9a-f]{6}$/i.test(args.color)) {
|
|
5198
|
+
throw new ValidationError("color must be a 6-digit hex, e.g. #16a34a");
|
|
5199
|
+
}
|
|
5200
|
+
color = args.color;
|
|
5201
|
+
}
|
|
5202
|
+
const db = getDb();
|
|
5203
|
+
const definedBy = await resolveCallerEmail(ctx.userId);
|
|
5204
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5205
|
+
await seedDefaultEdgeTypes(ctx.nestId);
|
|
5206
|
+
let created = false;
|
|
5207
|
+
await db.transaction(async (tx) => {
|
|
5208
|
+
const existing = await tx.get(
|
|
5209
|
+
"SELECT id FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
|
|
5210
|
+
[ctx.nestId, name]
|
|
5211
|
+
);
|
|
5212
|
+
if (existing) {
|
|
5213
|
+
await tx.run(
|
|
5214
|
+
`UPDATE edge_types SET name = ?, description = ?, direction = ?, is_flow = ?,
|
|
5215
|
+
condition_schema = ?, color = ?, updated_at = ? WHERE id = ?`,
|
|
5216
|
+
[name, args.description.trim(), direction, isFlow, conditionSchema, color, now, existing.id]
|
|
5217
|
+
);
|
|
5218
|
+
} else {
|
|
5219
|
+
created = true;
|
|
5220
|
+
await tx.run(
|
|
5221
|
+
`INSERT INTO edge_types
|
|
5222
|
+
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
5223
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
5224
|
+
[uuid8(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
|
|
5225
|
+
);
|
|
5226
|
+
}
|
|
5227
|
+
});
|
|
5228
|
+
const row = await db.get(
|
|
5229
|
+
"SELECT * FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
|
|
5230
|
+
[ctx.nestId, name]
|
|
5231
|
+
);
|
|
5232
|
+
return { created, edge_type: edgeTypeToResponse(row) };
|
|
5233
|
+
}
|
|
5234
|
+
async function deleteEdgeType(nestId, id) {
|
|
5235
|
+
await getDb().transaction(async (tx) => {
|
|
5236
|
+
const row = await tx.get(
|
|
5237
|
+
"SELECT id FROM edge_types WHERE id = ? AND nest_id = ?",
|
|
5238
|
+
[id, nestId]
|
|
5239
|
+
);
|
|
5240
|
+
if (!row) throw new NotFoundError("Edge type not found");
|
|
5241
|
+
const inUse = await tx.get(
|
|
5242
|
+
"SELECT COUNT(*) as c FROM edges WHERE type_id = ?",
|
|
5243
|
+
[id]
|
|
5244
|
+
);
|
|
5245
|
+
if (inUse.c > 0) {
|
|
5246
|
+
throw new ValidationError(
|
|
5247
|
+
`Edge type is in use by ${inUse.c} edge${inUse.c === 1 ? "" : "s"} \u2014 delete those first.`
|
|
5248
|
+
);
|
|
5249
|
+
}
|
|
5250
|
+
await tx.run("DELETE FROM edge_types WHERE id = ?", [id]);
|
|
5251
|
+
});
|
|
5252
|
+
return { deleted: true, edge_type_id: id };
|
|
5253
|
+
}
|
|
5254
|
+
|
|
5255
|
+
// src/mcp/tools.ts
|
|
5256
|
+
var MAX_HOPS = 10;
|
|
5257
|
+
function normalizeHops(raw) {
|
|
5258
|
+
if (raw == null) return 2;
|
|
5259
|
+
const n = Number(raw);
|
|
5260
|
+
if (!Number.isFinite(n)) return 2;
|
|
5261
|
+
return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
|
|
5262
|
+
}
|
|
5263
|
+
var TOOL_DEFINITIONS = [
|
|
5264
|
+
{
|
|
5265
|
+
name: "context_init",
|
|
5266
|
+
description: "Load the vault's CONTEXT.md which contains operating instructions and behavioral guidelines. Call this FIRST in every conversation.",
|
|
5267
|
+
inputSchema: { type: "object", properties: {} }
|
|
5268
|
+
},
|
|
5269
|
+
{
|
|
5270
|
+
name: "context_overview",
|
|
5271
|
+
description: "Get a complete map of the vault: total node count, types, tags, and a title+snippet for every node.",
|
|
5272
|
+
inputSchema: { type: "object", properties: {} }
|
|
5273
|
+
},
|
|
5274
|
+
{
|
|
5275
|
+
name: "context_search",
|
|
5276
|
+
description: "Full-text keyword search across all node content, titles, tags, and metadata.",
|
|
5277
|
+
inputSchema: {
|
|
5278
|
+
type: "object",
|
|
5279
|
+
properties: {
|
|
5280
|
+
query: { type: "string", description: "Search terms" }
|
|
5281
|
+
},
|
|
5282
|
+
required: ["query"]
|
|
5283
|
+
}
|
|
5284
|
+
},
|
|
5285
|
+
{
|
|
5286
|
+
name: "context_query",
|
|
5287
|
+
description: "Run a structured selector query. Supports: #tag, type:X, [[Title]], scope:X. Combine with +AND, |OR, -NOT.",
|
|
5288
|
+
inputSchema: {
|
|
5289
|
+
type: "object",
|
|
5290
|
+
properties: {
|
|
5291
|
+
query: { type: "string", description: "Selector query" },
|
|
5292
|
+
hops: {
|
|
5293
|
+
type: "number",
|
|
5294
|
+
description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
|
|
5295
|
+
}
|
|
5296
|
+
},
|
|
5297
|
+
required: ["query"]
|
|
5298
|
+
}
|
|
5299
|
+
},
|
|
5300
|
+
{
|
|
5301
|
+
name: "context_get",
|
|
5302
|
+
description: "Get the FULL content of a specific node by title or ID.",
|
|
5303
|
+
inputSchema: {
|
|
5304
|
+
type: "object",
|
|
5305
|
+
properties: {
|
|
5306
|
+
title: { type: "string", description: "Title of the node" },
|
|
5307
|
+
id: { type: "string", description: "ID of the node" }
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
},
|
|
5311
|
+
{
|
|
5312
|
+
name: "context_list",
|
|
5313
|
+
description: "Browse vault contents with optional type, tag, or limit filters.",
|
|
5314
|
+
inputSchema: {
|
|
5315
|
+
type: "object",
|
|
5316
|
+
properties: {
|
|
5317
|
+
type: { type: "string", description: "Filter by node type" },
|
|
5318
|
+
tag: { type: "string", description: "Filter by tag" },
|
|
5319
|
+
limit: { type: "number", description: "Max nodes to return" }
|
|
5320
|
+
}
|
|
5321
|
+
}
|
|
5322
|
+
},
|
|
5323
|
+
{
|
|
5324
|
+
name: "context_resolve",
|
|
5325
|
+
description: "Full context resolution \u2014 run a selector and return complete node content, respecting a token budget.",
|
|
5326
|
+
inputSchema: {
|
|
5327
|
+
type: "object",
|
|
5328
|
+
properties: {
|
|
5329
|
+
selector: {
|
|
5330
|
+
type: "string",
|
|
5331
|
+
description: "Selector query string"
|
|
5332
|
+
},
|
|
5333
|
+
max_tokens: {
|
|
3861
5334
|
type: "number",
|
|
3862
5335
|
description: "Approximate token budget (default: 8000)"
|
|
3863
5336
|
},
|
|
@@ -4051,6 +5524,26 @@ var TOOL_DEFINITIONS = [
|
|
|
4051
5524
|
required: ["email"]
|
|
4052
5525
|
}
|
|
4053
5526
|
},
|
|
5527
|
+
{
|
|
5528
|
+
name: "context_grant",
|
|
5529
|
+
description: "Share a SINGLE document or a FOLDER with a person (not the whole nest). The recipient can reach exactly that document (or everything under that folder) and nothing else. read = view, write = view+edit. Owner/admin only.",
|
|
5530
|
+
inputSchema: {
|
|
5531
|
+
type: "object",
|
|
5532
|
+
properties: {
|
|
5533
|
+
email: { type: "string", description: "Email of the person to share with" },
|
|
5534
|
+
target_type: {
|
|
5535
|
+
type: "string",
|
|
5536
|
+
description: "'document' to share one node, or 'folder' to share a path prefix"
|
|
5537
|
+
},
|
|
5538
|
+
target: {
|
|
5539
|
+
type: "string",
|
|
5540
|
+
description: "Node id (e.g. nodes/win-loss-log) for a document, or a folder prefix (e.g. nodes/gtm/deals) for a folder"
|
|
5541
|
+
},
|
|
5542
|
+
role: { type: "string", description: "read (default) or write" }
|
|
5543
|
+
},
|
|
5544
|
+
required: ["email", "target_type", "target"]
|
|
5545
|
+
}
|
|
5546
|
+
},
|
|
4054
5547
|
// ─── Unsynced Folder Tools ─────────────────────────────────────────
|
|
4055
5548
|
{
|
|
4056
5549
|
name: "context_unsynced_list",
|
|
@@ -4084,6 +5577,26 @@ async function resolveLlmBody(ctx, node) {
|
|
|
4084
5577
|
}
|
|
4085
5578
|
}
|
|
4086
5579
|
async function handleToolCall(toolName, args, ctx) {
|
|
5580
|
+
const started = Date.now();
|
|
5581
|
+
let status = 200;
|
|
5582
|
+
try {
|
|
5583
|
+
return await runTool(toolName, args, ctx);
|
|
5584
|
+
} catch (err) {
|
|
5585
|
+
status = 500;
|
|
5586
|
+
throw err;
|
|
5587
|
+
} finally {
|
|
5588
|
+
void logTraceEvent({
|
|
5589
|
+
kind: "mcp",
|
|
5590
|
+
tool: toolName,
|
|
5591
|
+
nestId: ctx.nestId,
|
|
5592
|
+
userId: ctx.userId,
|
|
5593
|
+
userEmail: ctx.userEmail,
|
|
5594
|
+
status,
|
|
5595
|
+
durationMs: Date.now() - started
|
|
5596
|
+
});
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
async function runTool(toolName, args, ctx) {
|
|
4087
5600
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
4088
5601
|
switch (toolName) {
|
|
4089
5602
|
case "context_init": {
|
|
@@ -4415,7 +5928,8 @@ ${list}`;
|
|
|
4415
5928
|
version: currentVersion,
|
|
4416
5929
|
requestedBy: ctx.userEmail,
|
|
4417
5930
|
note: args.note,
|
|
4418
|
-
priority: args.priority
|
|
5931
|
+
priority: args.priority,
|
|
5932
|
+
baseUrl: ctx.baseUrl
|
|
4419
5933
|
});
|
|
4420
5934
|
const resolved = await resolveStewardsForNode(
|
|
4421
5935
|
ctx.nestId,
|
|
@@ -4443,7 +5957,8 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
4443
5957
|
nodeId: node.id,
|
|
4444
5958
|
version: currentVersion,
|
|
4445
5959
|
approvedBy: ctx.userEmail,
|
|
4446
|
-
note: args.note
|
|
5960
|
+
note: args.note,
|
|
5961
|
+
baseUrl: ctx.baseUrl
|
|
4447
5962
|
});
|
|
4448
5963
|
return `Approved "${args.title}" v${currentVersion}. This version is now live for AI queries.${args.note ? `
|
|
4449
5964
|
Note: ${args.note}` : ""}`;
|
|
@@ -4464,7 +5979,8 @@ Note: ${args.note}` : ""}`;
|
|
|
4464
5979
|
nodeId: node.id,
|
|
4465
5980
|
version: currentVersion,
|
|
4466
5981
|
rejectedBy: ctx.userEmail,
|
|
4467
|
-
note: args.note
|
|
5982
|
+
note: args.note,
|
|
5983
|
+
baseUrl: ctx.baseUrl
|
|
4468
5984
|
});
|
|
4469
5985
|
return `Rejected "${args.title}" v${currentVersion}.
|
|
4470
5986
|
Reason: ${args.note}`;
|
|
@@ -4534,104 +6050,796 @@ ${list}`;
|
|
|
4534
6050
|
return `Failed to share nest: ${err.message}`;
|
|
4535
6051
|
}
|
|
4536
6052
|
}
|
|
4537
|
-
case "
|
|
4538
|
-
if (
|
|
4539
|
-
return "You don't have permission to
|
|
6053
|
+
case "context_grant": {
|
|
6054
|
+
if (!await canManageStewards(ctx.nestId, ctx.userId)) {
|
|
6055
|
+
return "You don't have permission to share documents in this nest (owner/admin only).";
|
|
6056
|
+
}
|
|
6057
|
+
const targetType = String(args.target_type || "");
|
|
6058
|
+
const target = String(args.target || "").trim();
|
|
6059
|
+
const role = String(args.role || "read");
|
|
6060
|
+
if (!["document", "folder"].includes(targetType)) {
|
|
6061
|
+
return "target_type must be 'document' or 'folder'.";
|
|
6062
|
+
}
|
|
6063
|
+
if (!target) return "target is required (a node id or folder prefix).";
|
|
6064
|
+
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
6065
|
+
try {
|
|
6066
|
+
const { createGrant: createGrant2 } = await import("./grants-service-MDLRPMZR.js");
|
|
6067
|
+
const db = (await import("./client-GI74NPIW.js")).getDb();
|
|
6068
|
+
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
6069
|
+
const e = normalizeEmail2(String(args.email || ""));
|
|
6070
|
+
if (!e) return "email is required.";
|
|
6071
|
+
let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
|
|
6072
|
+
if (!row) {
|
|
6073
|
+
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
6074
|
+
const { v4: uuid12 } = await import("uuid");
|
|
6075
|
+
const id = uuid12();
|
|
6076
|
+
await db.run(
|
|
6077
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
6078
|
+
[id, e, null, await hashPassword2(uuid12())]
|
|
6079
|
+
);
|
|
6080
|
+
row = { id };
|
|
6081
|
+
}
|
|
6082
|
+
await createGrant2({
|
|
6083
|
+
nestId: ctx.nestId,
|
|
6084
|
+
targetType,
|
|
6085
|
+
target,
|
|
6086
|
+
userId: row.id,
|
|
6087
|
+
role,
|
|
6088
|
+
grantedBy: ctx.userEmail
|
|
6089
|
+
});
|
|
6090
|
+
return `Shared ${targetType} \`${target}\` with **${args.email}** as ${role}.`;
|
|
6091
|
+
} catch (err) {
|
|
6092
|
+
return `Failed to share: ${err.message}`;
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
case "context_unsynced_list": {
|
|
6096
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
6097
|
+
return "You don't have permission to list unsynced folders. Server admin only.";
|
|
6098
|
+
}
|
|
6099
|
+
const folders = listUnsyncedFolders();
|
|
6100
|
+
if (!folders.length) return "No unsynced folders found.";
|
|
6101
|
+
const list = folders.map(
|
|
6102
|
+
(f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
|
|
6103
|
+
).join("\n");
|
|
6104
|
+
return `# Unsynced Folders (${folders.length})
|
|
6105
|
+
|
|
6106
|
+
${list}`;
|
|
6107
|
+
}
|
|
6108
|
+
case "context_sync_folder": {
|
|
6109
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
6110
|
+
return "You don't have permission to sync folders. Server admin only.";
|
|
6111
|
+
}
|
|
6112
|
+
try {
|
|
6113
|
+
const { nest, documents } = await syncUnsyncedFolder(
|
|
6114
|
+
userId,
|
|
6115
|
+
args.name,
|
|
6116
|
+
userEmail
|
|
6117
|
+
);
|
|
6118
|
+
return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
|
|
6119
|
+
} catch (err) {
|
|
6120
|
+
return `Failed to sync folder: ${err.message}`;
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
default:
|
|
6124
|
+
if (isWorkflowTool(toolName)) {
|
|
6125
|
+
return runWorkflowTool(toolName, args, ctx);
|
|
6126
|
+
}
|
|
6127
|
+
return `Unknown tool: ${toolName}`;
|
|
6128
|
+
}
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6131
|
+
// src/mcp/routes.ts
|
|
6132
|
+
import { z } from "zod";
|
|
6133
|
+
var mcpRoutes = new Hono10();
|
|
6134
|
+
async function getUserEmail2(userId) {
|
|
6135
|
+
const db = getDb();
|
|
6136
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
6137
|
+
return user?.email || "anonymous@localhost";
|
|
6138
|
+
}
|
|
6139
|
+
function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
6140
|
+
const server = new McpServer(
|
|
6141
|
+
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
6142
|
+
{ capabilities: { tools: {} } }
|
|
6143
|
+
);
|
|
6144
|
+
const toolDefs = config.FEATURE_WORKFLOW_PLANE ? [...TOOL_DEFINITIONS, ...WORKFLOW_TOOL_DEFINITIONS] : TOOL_DEFINITIONS;
|
|
6145
|
+
for (const tool of toolDefs) {
|
|
6146
|
+
const props = tool.inputSchema.properties || {};
|
|
6147
|
+
const required = tool.inputSchema.required || [];
|
|
6148
|
+
const shape = {};
|
|
6149
|
+
for (const [key, def] of Object.entries(props)) {
|
|
6150
|
+
let field;
|
|
6151
|
+
if (def.type === "string") field = z.string();
|
|
6152
|
+
else if (def.type === "number") field = z.number();
|
|
6153
|
+
else if (def.type === "array") field = z.array(z.string());
|
|
6154
|
+
else field = z.any();
|
|
6155
|
+
if (!required.includes(key)) field = field.optional();
|
|
6156
|
+
shape[key] = field;
|
|
6157
|
+
}
|
|
6158
|
+
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
6159
|
+
const engine = await engineCache.get(nestId);
|
|
6160
|
+
const text = await handleToolCall(tool.name, args, {
|
|
6161
|
+
storage: engine.storage,
|
|
6162
|
+
queryEngine: engine.query,
|
|
6163
|
+
versionManager: engine.versions,
|
|
6164
|
+
nestId,
|
|
6165
|
+
userId,
|
|
6166
|
+
userEmail,
|
|
6167
|
+
baseUrl
|
|
6168
|
+
});
|
|
6169
|
+
return { content: [{ type: "text", text }] };
|
|
6170
|
+
});
|
|
6171
|
+
}
|
|
6172
|
+
return server;
|
|
6173
|
+
}
|
|
6174
|
+
mcpRoutes.all("/", async (c) => {
|
|
6175
|
+
const nestId = c.req.param("nestId");
|
|
6176
|
+
const userId = c.get("userId");
|
|
6177
|
+
const userEmail = await getUserEmail2(userId);
|
|
6178
|
+
const server = createMcpServerForNest(
|
|
6179
|
+
nestId,
|
|
6180
|
+
userId,
|
|
6181
|
+
userEmail,
|
|
6182
|
+
requestBaseUrl(c.req.url)
|
|
6183
|
+
);
|
|
6184
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
6185
|
+
sessionIdGenerator: void 0,
|
|
6186
|
+
enableJsonResponse: true
|
|
6187
|
+
});
|
|
6188
|
+
await server.server.connect(transport);
|
|
6189
|
+
try {
|
|
6190
|
+
const response = await transport.handleRequest(c.req.raw);
|
|
6191
|
+
return response;
|
|
6192
|
+
} finally {
|
|
6193
|
+
await transport.close();
|
|
6194
|
+
await server.server.close();
|
|
6195
|
+
}
|
|
6196
|
+
});
|
|
6197
|
+
|
|
6198
|
+
// src/workflow/run-routes.ts
|
|
6199
|
+
import { Hono as Hono11 } from "hono";
|
|
6200
|
+
var runTriggerRoutes = new Hono11();
|
|
6201
|
+
runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
|
|
6202
|
+
const nestId = c.req.param("nestId");
|
|
6203
|
+
const agentNode = assertSafeNodeId(c.req.param("agentNode"));
|
|
6204
|
+
const body = await c.req.json().catch(() => ({}));
|
|
6205
|
+
const { storage } = await engineCache.get(nestId);
|
|
6206
|
+
const triggeredBy = await resolveCallerEmail(c.get("userId"));
|
|
6207
|
+
const bundle = await triggerRun({
|
|
6208
|
+
nestId,
|
|
6209
|
+
agentNode,
|
|
6210
|
+
inputs: body.inputs,
|
|
6211
|
+
triggeredBy,
|
|
6212
|
+
storage,
|
|
6213
|
+
parentRunId: body.parent_run_id
|
|
6214
|
+
});
|
|
6215
|
+
return c.json(bundle, 201);
|
|
6216
|
+
});
|
|
6217
|
+
var runListRoutes = new Hono11();
|
|
6218
|
+
runListRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
6219
|
+
const nestId = c.req.param("nestId");
|
|
6220
|
+
const canSeeDetail = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
6221
|
+
const result = await listRuns(nestId, {
|
|
6222
|
+
agent: c.req.query("agent") || void 0,
|
|
6223
|
+
status: c.req.query("status") || void 0,
|
|
6224
|
+
limit: Number(c.req.query("limit")) || void 0,
|
|
6225
|
+
canSeeDetail
|
|
6226
|
+
});
|
|
6227
|
+
return c.json(result);
|
|
6228
|
+
});
|
|
6229
|
+
var runDetailRoutes = new Hono11();
|
|
6230
|
+
async function loadRunWithPermission(c, runId, required) {
|
|
6231
|
+
const run = await loadRun(runId);
|
|
6232
|
+
if (!run) throw new NotFoundError("Run not found");
|
|
6233
|
+
const nestScope = c.get("nestScope");
|
|
6234
|
+
if (nestScope && nestScope !== run.nest_id) {
|
|
6235
|
+
throw new ForbiddenError("API key not authorized for this nest");
|
|
6236
|
+
}
|
|
6237
|
+
if (required === "write") {
|
|
6238
|
+
if (isSuspended()) {
|
|
6239
|
+
throw new AppError(503, `Server suspended by PromptOwl: ${getSuspensionReason()}`);
|
|
6240
|
+
}
|
|
6241
|
+
if (!getCurrentLicense()?.valid) {
|
|
6242
|
+
throw new AppError(503, "A valid PromptOwl license is required to write runs.");
|
|
6243
|
+
}
|
|
6244
|
+
}
|
|
6245
|
+
const perm = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(run.nest_id, c.get("userId"));
|
|
6246
|
+
if (perm === "none") {
|
|
6247
|
+
throw new NotFoundError("Run not found");
|
|
6248
|
+
}
|
|
6249
|
+
if (permissionLevel(perm) < permissionLevel(required)) {
|
|
6250
|
+
throw new ForbiddenError(
|
|
6251
|
+
`This run belongs to a nest where you lack ${required} access.`
|
|
6252
|
+
);
|
|
6253
|
+
}
|
|
6254
|
+
c.set("runPermission", perm);
|
|
6255
|
+
return run;
|
|
6256
|
+
}
|
|
6257
|
+
runDetailRoutes.post("/:id/steps", requireWorkflowPlane, async (c) => {
|
|
6258
|
+
const run = await loadRunWithPermission(c, c.req.param("id"), "write");
|
|
6259
|
+
const body = await c.req.json();
|
|
6260
|
+
const { seq } = await appendRunStep(run.id, body);
|
|
6261
|
+
return c.json({ ok: true, seq }, 201);
|
|
6262
|
+
});
|
|
6263
|
+
runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
6264
|
+
const run = await loadRunWithPermission(c, c.req.param("id"), "write");
|
|
6265
|
+
const body = await c.req.json();
|
|
6266
|
+
const { status } = await closeRun(run.id, body);
|
|
6267
|
+
return c.json({ ok: true, status });
|
|
6268
|
+
});
|
|
6269
|
+
runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
|
|
6270
|
+
const run = await loadRunWithPermission(c, c.req.param("id"), "read");
|
|
6271
|
+
const canSeeDetail = permissionLevel(c.get("runPermission")) >= permissionLevel("write");
|
|
6272
|
+
const result = await getRunTrace(run, { canSeeDetail });
|
|
6273
|
+
return c.json(result);
|
|
6274
|
+
});
|
|
6275
|
+
|
|
6276
|
+
// src/definitions/routes.ts
|
|
6277
|
+
import { Hono as Hono12 } from "hono";
|
|
6278
|
+
import { v4 as uuid9 } from "uuid";
|
|
6279
|
+
var definitionRoutes = new Hono12();
|
|
6280
|
+
definitionRoutes.get("/", async (c) => {
|
|
6281
|
+
const nestId = c.req.param("nestId");
|
|
6282
|
+
const db = getDb();
|
|
6283
|
+
const term = c.req.query("term")?.trim();
|
|
6284
|
+
if (term) {
|
|
6285
|
+
const row = await db.get(
|
|
6286
|
+
"SELECT * FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
|
|
6287
|
+
[nestId, term]
|
|
6288
|
+
);
|
|
6289
|
+
if (!row) throw new NotFoundError(`No definition for "${term}"`);
|
|
6290
|
+
return c.json({ definition: row });
|
|
6291
|
+
}
|
|
6292
|
+
const rows = await db.all(
|
|
6293
|
+
"SELECT * FROM definitions WHERE nest_id = ? ORDER BY LOWER(term)",
|
|
6294
|
+
[nestId]
|
|
6295
|
+
);
|
|
6296
|
+
return c.json({ count: rows.length, definitions: rows });
|
|
6297
|
+
});
|
|
6298
|
+
definitionRoutes.post("/", async (c) => {
|
|
6299
|
+
const nestId = c.req.param("nestId");
|
|
6300
|
+
const body = await c.req.json();
|
|
6301
|
+
if (body.term !== void 0 && typeof body.term !== "string")
|
|
6302
|
+
throw new ValidationError("term must be a string");
|
|
6303
|
+
if (body.definition !== void 0 && typeof body.definition !== "string")
|
|
6304
|
+
throw new ValidationError("definition must be a string");
|
|
6305
|
+
if (body.linked_tag !== void 0 && body.linked_tag !== null && typeof body.linked_tag !== "string")
|
|
6306
|
+
throw new ValidationError("linked_tag must be a string or null");
|
|
6307
|
+
if (body.editing_id !== void 0 && typeof body.editing_id !== "string")
|
|
6308
|
+
throw new ValidationError("editing_id must be a string");
|
|
6309
|
+
const term = body.term?.trim();
|
|
6310
|
+
const definition = body.definition?.trim();
|
|
6311
|
+
const editingId = body.editing_id?.trim() || void 0;
|
|
6312
|
+
if (!term || !definition) {
|
|
6313
|
+
throw new ValidationError("term and definition are required");
|
|
6314
|
+
}
|
|
6315
|
+
if (term.length > 200) {
|
|
6316
|
+
throw new ValidationError("term must be 200 characters or fewer");
|
|
6317
|
+
}
|
|
6318
|
+
const linkedTagProvided = "linked_tag" in body;
|
|
6319
|
+
const linkedTag = body.linked_tag?.trim() ? `#${body.linked_tag.trim().replace(/^#+/, "").toLowerCase()}` : null;
|
|
6320
|
+
const db = getDb();
|
|
6321
|
+
const definedBy = await resolveCallerEmail(c.get("userId"));
|
|
6322
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6323
|
+
const applyUpdate = async (tx, id) => {
|
|
6324
|
+
if (linkedTagProvided) {
|
|
6325
|
+
await tx.run(
|
|
6326
|
+
"UPDATE definitions SET term = ?, definition = ?, linked_tag = ?, defined_by = ?, updated_at = ? WHERE id = ?",
|
|
6327
|
+
[term, definition, linkedTag, definedBy, now, id]
|
|
6328
|
+
);
|
|
6329
|
+
} else {
|
|
6330
|
+
await tx.run(
|
|
6331
|
+
"UPDATE definitions SET term = ?, definition = ?, defined_by = ?, updated_at = ? WHERE id = ?",
|
|
6332
|
+
[term, definition, definedBy, now, id]
|
|
6333
|
+
);
|
|
6334
|
+
}
|
|
6335
|
+
};
|
|
6336
|
+
const { row, created } = await db.transaction(async (tx) => {
|
|
6337
|
+
const existing = await tx.get(
|
|
6338
|
+
"SELECT id FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
|
|
6339
|
+
[nestId, term]
|
|
6340
|
+
);
|
|
6341
|
+
let targetId;
|
|
6342
|
+
let wasCreated = false;
|
|
6343
|
+
if (editingId) {
|
|
6344
|
+
const editing = await tx.get(
|
|
6345
|
+
"SELECT id FROM definitions WHERE id = ? AND nest_id = ?",
|
|
6346
|
+
[editingId, nestId]
|
|
6347
|
+
);
|
|
6348
|
+
if (!editing) throw new NotFoundError("Definition not found");
|
|
6349
|
+
if (existing && existing.id !== editingId) {
|
|
6350
|
+
throw new ConflictError(`A definition for "${term}" already exists`);
|
|
4540
6351
|
}
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
6352
|
+
await applyUpdate(tx, editingId);
|
|
6353
|
+
targetId = editingId;
|
|
6354
|
+
} else if (existing) {
|
|
6355
|
+
await applyUpdate(tx, existing.id);
|
|
6356
|
+
targetId = existing.id;
|
|
6357
|
+
} else {
|
|
6358
|
+
targetId = uuid9();
|
|
6359
|
+
wasCreated = true;
|
|
6360
|
+
await tx.run(
|
|
6361
|
+
`INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
|
|
6362
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
6363
|
+
[targetId, nestId, term, definition, linkedTag, definedBy, now, now]
|
|
6364
|
+
);
|
|
4549
6365
|
}
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
6366
|
+
const fresh = await tx.get("SELECT * FROM definitions WHERE id = ?", [
|
|
6367
|
+
targetId
|
|
6368
|
+
]);
|
|
6369
|
+
return { row: fresh, created: wasCreated };
|
|
6370
|
+
});
|
|
6371
|
+
return c.json({ definition: row }, created ? 201 : 200);
|
|
6372
|
+
});
|
|
6373
|
+
definitionRoutes.delete("/:id", async (c) => {
|
|
6374
|
+
const nestId = c.req.param("nestId");
|
|
6375
|
+
const id = c.req.param("id");
|
|
6376
|
+
const db = getDb();
|
|
6377
|
+
const row = await db.get(
|
|
6378
|
+
"SELECT id FROM definitions WHERE id = ? AND nest_id = ?",
|
|
6379
|
+
[id, nestId]
|
|
6380
|
+
);
|
|
6381
|
+
if (!row) throw new NotFoundError("Definition not found");
|
|
6382
|
+
await db.run("DELETE FROM definitions WHERE id = ?", [id]);
|
|
6383
|
+
return c.json({ deleted: true });
|
|
6384
|
+
});
|
|
6385
|
+
|
|
6386
|
+
// src/nests/asset-routes.ts
|
|
6387
|
+
import { Hono as Hono13 } from "hono";
|
|
6388
|
+
import { v4 as uuid10 } from "uuid";
|
|
6389
|
+
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
6390
|
+
import { join as join3 } from "path";
|
|
6391
|
+
var assetRoutes = new Hono13();
|
|
6392
|
+
var CONTENT_TYPES = {
|
|
6393
|
+
png: "image/png",
|
|
6394
|
+
jpg: "image/jpeg",
|
|
6395
|
+
jpeg: "image/jpeg",
|
|
6396
|
+
gif: "image/gif",
|
|
6397
|
+
webp: "image/webp"
|
|
6398
|
+
};
|
|
6399
|
+
var SAFE_NAME = /^[0-9a-f-]{36}\.(png|jpe?g|gif|webp)$/;
|
|
6400
|
+
assetRoutes.post("/", async (c) => {
|
|
6401
|
+
const nestId = c.req.param("nestId");
|
|
6402
|
+
const body = await c.req.parseBody();
|
|
6403
|
+
const file = body["file"];
|
|
6404
|
+
if (!(file instanceof File)) {
|
|
6405
|
+
throw new ValidationError('multipart field "file" (an image) is required');
|
|
6406
|
+
}
|
|
6407
|
+
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
|
6408
|
+
if (!CONTENT_TYPES[ext]) {
|
|
6409
|
+
throw new ValidationError(
|
|
6410
|
+
`unsupported image type ".${ext}" \u2014 allowed: ${Object.keys(CONTENT_TYPES).join(", ")}`
|
|
6411
|
+
);
|
|
6412
|
+
}
|
|
6413
|
+
const name = `${uuid10()}.${ext === "jpeg" ? "jpg" : ext}`;
|
|
6414
|
+
const dir = join3(resolveNestPath(nestId), "assets");
|
|
6415
|
+
await mkdir(dir, { recursive: true });
|
|
6416
|
+
await writeFile(join3(dir, name), Buffer.from(await file.arrayBuffer()));
|
|
6417
|
+
return c.json(
|
|
6418
|
+
{
|
|
6419
|
+
file: name,
|
|
6420
|
+
url: `/nests/${nestId}/assets/${name}`,
|
|
6421
|
+
markdown: `![${file.name.replace(/\.[^.]+$/, "")}](/nests/${nestId}/assets/${name})`
|
|
6422
|
+
},
|
|
6423
|
+
201
|
|
6424
|
+
);
|
|
6425
|
+
});
|
|
6426
|
+
assetRoutes.get("/:file", async (c) => {
|
|
6427
|
+
const nestId = c.req.param("nestId");
|
|
6428
|
+
const name = c.req.param("file");
|
|
6429
|
+
if (!SAFE_NAME.test(name)) {
|
|
6430
|
+
throw new ValidationError("invalid asset name");
|
|
6431
|
+
}
|
|
6432
|
+
let bytes;
|
|
6433
|
+
try {
|
|
6434
|
+
bytes = await readFile2(join3(resolveNestPath(nestId), "assets", name));
|
|
6435
|
+
} catch {
|
|
6436
|
+
throw new NotFoundError("Asset not found");
|
|
6437
|
+
}
|
|
6438
|
+
const ext = name.split(".").pop();
|
|
6439
|
+
return c.body(new Uint8Array(bytes), 200, {
|
|
6440
|
+
"Content-Type": CONTENT_TYPES[ext],
|
|
6441
|
+
// uuid names never change content — cache hard.
|
|
6442
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
6443
|
+
});
|
|
6444
|
+
});
|
|
6445
|
+
|
|
6446
|
+
// src/mcp/server-routes.ts
|
|
6447
|
+
import { Hono as Hono14 } from "hono";
|
|
6448
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6449
|
+
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6450
|
+
import { z as z2 } from "zod";
|
|
6451
|
+
async function getUserEmail3(userId) {
|
|
6452
|
+
const db = getDb();
|
|
6453
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
6454
|
+
userId
|
|
6455
|
+
]);
|
|
6456
|
+
return user?.email || "anonymous@localhost";
|
|
6457
|
+
}
|
|
6458
|
+
async function canRead(nestId, userId) {
|
|
6459
|
+
if (config.AUTH_MODE === "open") return true;
|
|
6460
|
+
const perm = await resolveNestPermission(nestId, userId);
|
|
6461
|
+
return permissionLevel(perm) >= permissionLevel("read");
|
|
6462
|
+
}
|
|
6463
|
+
async function resolveNestRef(ref, userId) {
|
|
6464
|
+
const db = getDb();
|
|
6465
|
+
const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
|
|
6466
|
+
if (byId) return byId;
|
|
6467
|
+
const byName = await db.all(
|
|
6468
|
+
"SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
|
|
6469
|
+
[ref, ref]
|
|
6470
|
+
);
|
|
6471
|
+
if (byName.length <= 1) return byName[0] ?? null;
|
|
6472
|
+
const readable = (await Promise.all(
|
|
6473
|
+
byName.map(async (n) => await canRead(n.id, userId) ? n : null)
|
|
6474
|
+
)).filter((n) => n !== null);
|
|
6475
|
+
return readable.length === 1 ? readable[0] : null;
|
|
6476
|
+
}
|
|
6477
|
+
async function buildNestIndex(userId, nestScope) {
|
|
6478
|
+
const [owned, shared, publicExtras] = await Promise.all([
|
|
6479
|
+
listNests(userId),
|
|
6480
|
+
listSharedNests(userId),
|
|
6481
|
+
listPublicNests(userId)
|
|
6482
|
+
]);
|
|
6483
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6484
|
+
const rows = [];
|
|
6485
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
6486
|
+
if (seen.has(n.id)) continue;
|
|
6487
|
+
if (nestScope && n.id !== nestScope) continue;
|
|
6488
|
+
seen.add(n.id);
|
|
6489
|
+
rows.push(n);
|
|
6490
|
+
}
|
|
6491
|
+
return Promise.all(
|
|
6492
|
+
rows.map(async (n) => {
|
|
6493
|
+
let permission = "read";
|
|
6494
|
+
try {
|
|
6495
|
+
permission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(n.id, userId);
|
|
6496
|
+
} catch {
|
|
4553
6497
|
}
|
|
6498
|
+
let document_count = null;
|
|
4554
6499
|
try {
|
|
4555
|
-
const {
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
userEmail
|
|
4559
|
-
);
|
|
4560
|
-
return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
|
|
4561
|
-
} catch (err) {
|
|
4562
|
-
return `Failed to sync folder: ${err.message}`;
|
|
6500
|
+
const { storage } = await engineCache.get(n.id);
|
|
6501
|
+
document_count = (await storage.discoverDocuments()).length;
|
|
6502
|
+
} catch {
|
|
4563
6503
|
}
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
6504
|
+
return {
|
|
6505
|
+
id: n.id,
|
|
6506
|
+
name: n.name,
|
|
6507
|
+
description: n.description,
|
|
6508
|
+
visibility: n.visibility,
|
|
6509
|
+
permission,
|
|
6510
|
+
document_count
|
|
6511
|
+
};
|
|
6512
|
+
})
|
|
6513
|
+
);
|
|
6514
|
+
}
|
|
6515
|
+
function renderNestIndexMarkdown(nests, baseUrl) {
|
|
6516
|
+
const lines = [
|
|
6517
|
+
"# Nest index",
|
|
6518
|
+
"",
|
|
6519
|
+
`${nests.length} nest${nests.length === 1 ? "" : "s"} accessible with these credentials. Full interaction manual: ${baseUrl}/llms.txt`,
|
|
6520
|
+
""
|
|
6521
|
+
];
|
|
6522
|
+
for (const n of nests) {
|
|
6523
|
+
lines.push(`## ${n.name}`);
|
|
6524
|
+
lines.push("");
|
|
6525
|
+
lines.push(`- id: \`${n.id}\``);
|
|
6526
|
+
if (n.description) lines.push(`- description: ${n.description}`);
|
|
6527
|
+
lines.push(`- permission: ${n.permission} \xB7 visibility: ${n.visibility}`);
|
|
6528
|
+
if (n.document_count !== null)
|
|
6529
|
+
lines.push(`- documents: ${n.document_count}`);
|
|
6530
|
+
lines.push(
|
|
6531
|
+
`- retrieve: \`POST ${baseUrl}/nests/${n.id}/context\` \xB7 mcp: \`${baseUrl}/nests/${n.id}/mcp\` \xB7 export: \`${baseUrl}/nests/${n.id}/export?format=markdown\``
|
|
6532
|
+
);
|
|
6533
|
+
lines.push("");
|
|
4567
6534
|
}
|
|
6535
|
+
return lines.join("\n");
|
|
4568
6536
|
}
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
6537
|
+
var NEST_ARG = {
|
|
6538
|
+
nest: {
|
|
6539
|
+
type: "string",
|
|
6540
|
+
description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
|
|
6541
|
+
}
|
|
6542
|
+
};
|
|
6543
|
+
var SERVER_TOOL_DEFINITIONS = [
|
|
6544
|
+
{
|
|
6545
|
+
name: "nest_index",
|
|
6546
|
+
description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST, then target a nest with the other tools.",
|
|
6547
|
+
inputSchema: { type: "object", properties: {} }
|
|
6548
|
+
},
|
|
6549
|
+
{
|
|
6550
|
+
name: "context_query",
|
|
6551
|
+
description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
|
|
6552
|
+
inputSchema: {
|
|
6553
|
+
type: "object",
|
|
6554
|
+
properties: {
|
|
6555
|
+
...NEST_ARG,
|
|
6556
|
+
query: { type: "string", description: "Selector query" },
|
|
6557
|
+
hops: {
|
|
6558
|
+
type: "number",
|
|
6559
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
6560
|
+
}
|
|
6561
|
+
},
|
|
6562
|
+
required: ["nest", "query"]
|
|
6563
|
+
}
|
|
6564
|
+
},
|
|
6565
|
+
{
|
|
6566
|
+
name: "context_search",
|
|
6567
|
+
description: "Full-text keyword search across one nest.",
|
|
6568
|
+
inputSchema: {
|
|
6569
|
+
type: "object",
|
|
6570
|
+
properties: {
|
|
6571
|
+
...NEST_ARG,
|
|
6572
|
+
query: { type: "string", description: "Search terms" }
|
|
6573
|
+
},
|
|
6574
|
+
required: ["nest", "query"]
|
|
6575
|
+
}
|
|
6576
|
+
},
|
|
6577
|
+
{
|
|
6578
|
+
name: "context_get",
|
|
6579
|
+
description: "Get the FULL content of a node in a nest by title or id.",
|
|
6580
|
+
inputSchema: {
|
|
6581
|
+
type: "object",
|
|
6582
|
+
properties: {
|
|
6583
|
+
...NEST_ARG,
|
|
6584
|
+
title: { type: "string", description: "Title of the node" },
|
|
6585
|
+
id: { type: "string", description: "ID of the node" }
|
|
6586
|
+
},
|
|
6587
|
+
required: ["nest"]
|
|
6588
|
+
}
|
|
6589
|
+
},
|
|
6590
|
+
{
|
|
6591
|
+
name: "context_list",
|
|
6592
|
+
description: "Browse one nest's contents with optional type, tag, or limit filters.",
|
|
6593
|
+
inputSchema: {
|
|
6594
|
+
type: "object",
|
|
6595
|
+
properties: {
|
|
6596
|
+
...NEST_ARG,
|
|
6597
|
+
type: { type: "string", description: "Filter by node type" },
|
|
6598
|
+
tag: { type: "string", description: "Filter by tag" },
|
|
6599
|
+
limit: { type: "number", description: "Max nodes to return" }
|
|
6600
|
+
},
|
|
6601
|
+
required: ["nest"]
|
|
6602
|
+
}
|
|
6603
|
+
},
|
|
6604
|
+
{
|
|
6605
|
+
name: "context_overview",
|
|
6606
|
+
description: "Map of one nest: node count, types, tags, title+snippet per node.",
|
|
6607
|
+
inputSchema: {
|
|
6608
|
+
type: "object",
|
|
6609
|
+
properties: { ...NEST_ARG },
|
|
6610
|
+
required: ["nest"]
|
|
6611
|
+
}
|
|
6612
|
+
},
|
|
6613
|
+
{
|
|
6614
|
+
name: "context_resolve",
|
|
6615
|
+
description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
|
|
6616
|
+
inputSchema: {
|
|
6617
|
+
type: "object",
|
|
6618
|
+
properties: {
|
|
6619
|
+
...NEST_ARG,
|
|
6620
|
+
selector: { type: "string", description: "Selector query string" },
|
|
6621
|
+
max_tokens: {
|
|
6622
|
+
type: "number",
|
|
6623
|
+
description: "Approximate token budget (default: 8000)"
|
|
6624
|
+
},
|
|
6625
|
+
hops: {
|
|
6626
|
+
type: "number",
|
|
6627
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
6628
|
+
}
|
|
6629
|
+
},
|
|
6630
|
+
required: ["nest", "selector"]
|
|
6631
|
+
}
|
|
6632
|
+
}
|
|
6633
|
+
];
|
|
6634
|
+
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
6635
|
+
if (toolName === "nest_index") {
|
|
6636
|
+
const nests = await buildNestIndex(userId, nestScope);
|
|
6637
|
+
if (!nests.length) return "No nests accessible with these credentials.";
|
|
6638
|
+
return renderNestIndexMarkdown(nests, baseUrl);
|
|
6639
|
+
}
|
|
6640
|
+
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
6641
|
+
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
6642
|
+
if (!nest) return inaccessible;
|
|
6643
|
+
if (nestScope && nest.id !== nestScope) return inaccessible;
|
|
6644
|
+
if (!await canRead(nest.id, userId)) return inaccessible;
|
|
6645
|
+
const engine = await engineCache.get(nest.id);
|
|
6646
|
+
return handleToolCall(toolName, args, {
|
|
6647
|
+
storage: engine.storage,
|
|
6648
|
+
queryEngine: engine.query,
|
|
6649
|
+
versionManager: engine.versions,
|
|
6650
|
+
nestId: nest.id,
|
|
6651
|
+
userId,
|
|
6652
|
+
userEmail,
|
|
6653
|
+
baseUrl
|
|
6654
|
+
});
|
|
4577
6655
|
}
|
|
4578
|
-
function
|
|
4579
|
-
const server = new
|
|
4580
|
-
{ name:
|
|
6656
|
+
function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
6657
|
+
const server = new McpServer2(
|
|
6658
|
+
{ name: "contextnest-server", version: "1.0.0" },
|
|
4581
6659
|
{ capabilities: { tools: {} } }
|
|
4582
6660
|
);
|
|
4583
|
-
for (const tool of
|
|
6661
|
+
for (const tool of SERVER_TOOL_DEFINITIONS) {
|
|
4584
6662
|
const props = tool.inputSchema.properties || {};
|
|
4585
6663
|
const required = tool.inputSchema.required || [];
|
|
4586
6664
|
const shape = {};
|
|
4587
6665
|
for (const [key, def] of Object.entries(props)) {
|
|
4588
6666
|
let field;
|
|
4589
|
-
if (def.type === "string") field =
|
|
4590
|
-
else if (def.type === "number") field =
|
|
4591
|
-
else if (def.type === "array") field =
|
|
4592
|
-
else field =
|
|
6667
|
+
if (def.type === "string") field = z2.string();
|
|
6668
|
+
else if (def.type === "number") field = z2.number();
|
|
6669
|
+
else if (def.type === "array") field = z2.array(z2.string());
|
|
6670
|
+
else field = z2.any();
|
|
4593
6671
|
if (!required.includes(key)) field = field.optional();
|
|
4594
6672
|
shape[key] = field;
|
|
4595
6673
|
}
|
|
4596
6674
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
4597
|
-
const
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
queryEngine: engine.query,
|
|
4601
|
-
versionManager: engine.versions,
|
|
4602
|
-
nestId,
|
|
6675
|
+
const text = await handleServerToolCall(
|
|
6676
|
+
tool.name,
|
|
6677
|
+
args,
|
|
4603
6678
|
userId,
|
|
4604
|
-
userEmail
|
|
4605
|
-
|
|
6679
|
+
userEmail,
|
|
6680
|
+
nestScope,
|
|
6681
|
+
baseUrl
|
|
6682
|
+
);
|
|
4606
6683
|
return { content: [{ type: "text", text }] };
|
|
4607
6684
|
});
|
|
4608
6685
|
}
|
|
4609
6686
|
return server;
|
|
4610
6687
|
}
|
|
4611
|
-
|
|
4612
|
-
|
|
6688
|
+
var serverMcpRoutes = new Hono14();
|
|
6689
|
+
serverMcpRoutes.all("/", async (c) => {
|
|
4613
6690
|
const userId = c.get("userId");
|
|
4614
|
-
const userEmail = await
|
|
4615
|
-
const server =
|
|
4616
|
-
|
|
6691
|
+
const userEmail = await getUserEmail3(userId);
|
|
6692
|
+
const server = createServerMcp(
|
|
6693
|
+
userId,
|
|
6694
|
+
userEmail,
|
|
6695
|
+
c.get("nestScope"),
|
|
6696
|
+
requestBaseUrl(c.req.url)
|
|
6697
|
+
);
|
|
6698
|
+
const transport = new WebStandardStreamableHTTPServerTransport2({
|
|
4617
6699
|
sessionIdGenerator: void 0,
|
|
4618
6700
|
enableJsonResponse: true
|
|
4619
6701
|
});
|
|
4620
6702
|
await server.server.connect(transport);
|
|
4621
6703
|
try {
|
|
4622
|
-
|
|
4623
|
-
return response;
|
|
6704
|
+
return await transport.handleRequest(c.req.raw);
|
|
4624
6705
|
} finally {
|
|
4625
6706
|
await transport.close();
|
|
4626
6707
|
await server.server.close();
|
|
4627
6708
|
}
|
|
4628
6709
|
});
|
|
6710
|
+
var nestIndexRoutes = new Hono14();
|
|
6711
|
+
nestIndexRoutes.get("/", async (c) => {
|
|
6712
|
+
const userId = c.get("userId");
|
|
6713
|
+
const nests = await buildNestIndex(userId, c.get("nestScope"));
|
|
6714
|
+
if (c.req.query("format") === "json") {
|
|
6715
|
+
return c.json({ count: nests.length, nests });
|
|
6716
|
+
}
|
|
6717
|
+
const md = renderNestIndexMarkdown(nests, requestBaseUrl(c.req.url));
|
|
6718
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
6719
|
+
});
|
|
6720
|
+
function renderLlmsTxt(baseUrl) {
|
|
6721
|
+
const B = baseUrl || "<server-url>";
|
|
6722
|
+
return `# ContextNest Community Server
|
|
6723
|
+
|
|
6724
|
+
Self-hosted context governance server. Nests are versioned knowledge vaults
|
|
6725
|
+
of markdown documents with tags, wiki-links ([[Title]]), and steward
|
|
6726
|
+
governance. This file is the complete interaction manual for agents.
|
|
6727
|
+
|
|
6728
|
+
## Authentication
|
|
6729
|
+
|
|
6730
|
+
Every request (except this file and /health): \`Authorization: Bearer cnst_<api-key>\`.
|
|
6731
|
+
Keys are minted in the web UI (Connect dialog \u2192 Generate Key). A key is
|
|
6732
|
+
either user-level (sees every nest the user can read) or scoped to one nest.
|
|
6733
|
+
Servers in "open" auth mode need no key.
|
|
6734
|
+
|
|
6735
|
+
## Start here
|
|
6736
|
+
|
|
6737
|
+
1. \`GET ${B}/index\` \u2014 your grounded nest index (markdown; \`?format=json\` for JSON).
|
|
6738
|
+
One entry per accessible nest: name, id, permission, document count, and
|
|
6739
|
+
the per-nest URLs below.
|
|
6740
|
+
2. Pick a nest id, then retrieve with the API or MCP.
|
|
6741
|
+
|
|
6742
|
+
## Deterministic retrieval API
|
|
6743
|
+
|
|
6744
|
+
Same inputs \u2192 same context, every time. The primary call:
|
|
6745
|
+
|
|
6746
|
+
\`\`\`
|
|
6747
|
+
POST ${B}/nests/<nest-id>/context
|
|
6748
|
+
Content-Type: application/json
|
|
6749
|
+
{ "selector": "#gtm", "hops": 2, "max_tokens": 4000, "include_drafts": false }
|
|
6750
|
+
\`\`\`
|
|
6751
|
+
|
|
6752
|
+
Returns \`{ context, nodes[], trace }\` \u2014 assembled markdown plus a trace with
|
|
6753
|
+
\`hops_used\` and \`nodes_traversed\` so you can audit exactly what was pulled.
|
|
6754
|
+
|
|
6755
|
+
Selector grammar:
|
|
6756
|
+
- \`#tag\` \u2014 nodes carrying a tag
|
|
6757
|
+
- \`type:document\` \u2014 nodes of a type
|
|
6758
|
+
- \`[[Title]]\` \u2014 a node by exact title
|
|
6759
|
+
- \`scope:team\` \u2014 nodes by visibility scope
|
|
6760
|
+
- Combine: \`+AND\`, \`|OR\`, \`-NOT\` (e.g. \`#gtm+type:document-#draft\`)
|
|
6761
|
+
|
|
6762
|
+
Graph-hop logic: \`hops\` is the traversal depth over wiki-links from the
|
|
6763
|
+
selector matches. \`hops: 0\` = exactly the matches; \`hops: 1\` = matches plus
|
|
6764
|
+
directly linked nodes; \`hops: 2\` (default) and up walk the link graph
|
|
6765
|
+
further out. For an exact set with no expansion, OR the selectors together
|
|
6766
|
+
(\`[[A]]|[[B]]\`) and pass \`hops: 0\`.
|
|
6767
|
+
|
|
6768
|
+
Skip logic \u2014 what retrieval excludes and why:
|
|
6769
|
+
- Draft/unapproved documents are skipped unless \`include_drafts: true\`
|
|
6770
|
+
(and public/read-only callers always get approved content only).
|
|
6771
|
+
- \`max_tokens\` is a budget: documents that don't fit are skipped, and the
|
|
6772
|
+
trace reports \`truncated_by_budget\` so silence never means "complete".
|
|
6773
|
+
- Documents are deduped by id when a title match and a selector match overlap.
|
|
6774
|
+
|
|
6775
|
+
Other read endpoints (all under \`${B}/nests/<nest-id>\`):
|
|
6776
|
+
- \`POST /query\` \u2014 \`{ "query": "<selector>", "hops": N }\`; matches + snippets, no bodies
|
|
6777
|
+
- \`GET /search?q=<terms>\` \u2014 full-text search
|
|
6778
|
+
- \`GET /overview\` \u2014 node count, types, tags
|
|
6779
|
+
- \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
|
|
6780
|
+
- \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
|
|
6781
|
+
- \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
|
|
6782
|
+
|
|
6783
|
+
Write endpoints (need write permission):
|
|
6784
|
+
- \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
|
|
6785
|
+
- \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
|
|
6786
|
+
- \`DELETE /nodes/<node-id>\`
|
|
6787
|
+
- Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
|
|
6788
|
+
|
|
6789
|
+
## MCP (Model Context Protocol)
|
|
6790
|
+
|
|
6791
|
+
Streamable HTTP, stateless. Two endpoints:
|
|
6792
|
+
|
|
6793
|
+
- \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
|
|
6794
|
+
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
6795
|
+
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
6796
|
+
each takes a \`nest\` argument (id or exact name) plus the same
|
|
6797
|
+
selector/hops/max_tokens parameters as the REST API. Read-only.
|
|
6798
|
+
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
6799
|
+
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
6800
|
+
|
|
6801
|
+
Claude Code / Cursor / VS Code connect natively via HTTP with the
|
|
6802
|
+
\`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
|
|
6803
|
+
|
|
6804
|
+
\`\`\`
|
|
6805
|
+
npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
|
|
6806
|
+
\`\`\`
|
|
6807
|
+
|
|
6808
|
+
## ctx CLI (npm: @promptowl/contextnest-cli)
|
|
6809
|
+
|
|
6810
|
+
\`npm i -g @promptowl/contextnest-cli\`
|
|
6811
|
+
|
|
6812
|
+
- \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
|
|
6813
|
+
vault into a nest (add \`--include-drafts\` for unpublished nodes).
|
|
6814
|
+
- All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
|
|
6815
|
+
a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
|
|
6816
|
+
there is currently no remote \`ctx query\`.
|
|
6817
|
+
|
|
6818
|
+
## npm packages
|
|
6819
|
+
|
|
6820
|
+
- \`@promptowl/contextnest-community\` \u2014 this server
|
|
6821
|
+
- \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
|
|
6822
|
+
- \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
|
|
6823
|
+
|
|
6824
|
+
## Conventions
|
|
6825
|
+
|
|
6826
|
+
- Node ids are paths: \`nodes/<slug>\` or nested \`nodes/<folder>/<slug>\`.
|
|
6827
|
+
- Tags are \`#lowercase\`. Wiki-links are \`[[Exact Title]]\`.
|
|
6828
|
+
- Every write is versioned and hash-chained; governed nests route writes
|
|
6829
|
+
through steward review before they become AI-visible.
|
|
6830
|
+
`;
|
|
6831
|
+
}
|
|
6832
|
+
var llmsTxtRoutes = new Hono14();
|
|
6833
|
+
llmsTxtRoutes.get("/", (c) => {
|
|
6834
|
+
const md = renderLlmsTxt(requestBaseUrl(c.req.url));
|
|
6835
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
6836
|
+
});
|
|
4629
6837
|
|
|
4630
6838
|
// src/governance/routes.ts
|
|
4631
|
-
import { Hono as
|
|
6839
|
+
import { Hono as Hono15 } from "hono";
|
|
4632
6840
|
|
|
4633
6841
|
// src/governance/comment-service.ts
|
|
4634
|
-
import { v4 as
|
|
6842
|
+
import { v4 as uuid11 } from "uuid";
|
|
4635
6843
|
async function createComment(params) {
|
|
4636
6844
|
const db = getDb();
|
|
4637
6845
|
const body = (params.body ?? "").trim();
|
|
@@ -4647,7 +6855,7 @@ async function createComment(params) {
|
|
|
4647
6855
|
throw new Error("Parent comment not found on this node");
|
|
4648
6856
|
}
|
|
4649
6857
|
}
|
|
4650
|
-
const id =
|
|
6858
|
+
const id = uuid11();
|
|
4651
6859
|
await db.run(
|
|
4652
6860
|
`INSERT INTO comments
|
|
4653
6861
|
(id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
|
|
@@ -4847,7 +7055,7 @@ function rowToComment(row) {
|
|
|
4847
7055
|
|
|
4848
7056
|
// src/governance/stewards-parser.ts
|
|
4849
7057
|
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
4850
|
-
import { join as
|
|
7058
|
+
import { join as join4 } from "path";
|
|
4851
7059
|
function parseStewardsYaml(content) {
|
|
4852
7060
|
const result = { version: 1 };
|
|
4853
7061
|
const lines = content.split("\n");
|
|
@@ -4913,9 +7121,9 @@ function parseEntry(str) {
|
|
|
4913
7121
|
function loadStewardsConfig(nestId) {
|
|
4914
7122
|
const nestPath = resolveNestPath(nestId);
|
|
4915
7123
|
const candidates = [
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
7124
|
+
join4(nestPath, "stewards.yaml"),
|
|
7125
|
+
join4(nestPath, "stewards.yml"),
|
|
7126
|
+
join4(nestPath, ".context", "stewards.yaml")
|
|
4919
7127
|
];
|
|
4920
7128
|
for (const candidatePath of candidates) {
|
|
4921
7129
|
if (existsSync(candidatePath)) {
|
|
@@ -4932,7 +7140,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
|
|
|
4932
7140
|
if (Number.isNaN(n) || n < 1) return def;
|
|
4933
7141
|
return Math.min(n, max);
|
|
4934
7142
|
}
|
|
4935
|
-
var governanceRoutes = new
|
|
7143
|
+
var governanceRoutes = new Hono15();
|
|
4936
7144
|
governanceRoutes.get("/stewards", async (c) => {
|
|
4937
7145
|
const nestId = c.req.param("nestId");
|
|
4938
7146
|
const scope = c.req.query("scope");
|
|
@@ -4961,7 +7169,7 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
4961
7169
|
governanceRoutes.post("/stewards", async (c) => {
|
|
4962
7170
|
const nestId = c.req.param("nestId");
|
|
4963
7171
|
const body = await c.req.json();
|
|
4964
|
-
const assignedBy = await
|
|
7172
|
+
const assignedBy = await getUserEmail4(c);
|
|
4965
7173
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
4966
7174
|
if (body.scope === "folder") {
|
|
4967
7175
|
throw new ValidationError(
|
|
@@ -5036,7 +7244,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
5036
7244
|
limit,
|
|
5037
7245
|
offset
|
|
5038
7246
|
});
|
|
5039
|
-
const email = await
|
|
7247
|
+
const email = await getUserEmail4(c);
|
|
5040
7248
|
const canReviewCache = /* @__PURE__ */ new Map();
|
|
5041
7249
|
const requests = [];
|
|
5042
7250
|
for (const r of result.requests) {
|
|
@@ -5060,7 +7268,7 @@ governanceRoutes.get("/external-edits", async (c) => {
|
|
|
5060
7268
|
});
|
|
5061
7269
|
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
5062
7270
|
const nestId = c.req.param("nestId");
|
|
5063
|
-
const actor = await
|
|
7271
|
+
const actor = await getUserEmail4(c);
|
|
5064
7272
|
const result = await scanNestForDrift(nestId, actor);
|
|
5065
7273
|
return c.json(result);
|
|
5066
7274
|
});
|
|
@@ -5070,7 +7278,7 @@ governanceRoutes.get("/activity", async (c) => {
|
|
|
5070
7278
|
const activity = await getActivity({ nestId, limit });
|
|
5071
7279
|
return c.json({ activity });
|
|
5072
7280
|
});
|
|
5073
|
-
var governanceNodeRoutes = new
|
|
7281
|
+
var governanceNodeRoutes = new Hono15();
|
|
5074
7282
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
5075
7283
|
const nestId = c.req.param("nestId");
|
|
5076
7284
|
const nodeId = c.req.param("nodeId");
|
|
@@ -5129,7 +7337,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
|
5129
7337
|
const nestId = c.req.param("nestId");
|
|
5130
7338
|
const nodeId = c.req.param("nodeId");
|
|
5131
7339
|
const body = await c.req.json();
|
|
5132
|
-
const author = await
|
|
7340
|
+
const author = await getUserEmail4(c);
|
|
5133
7341
|
try {
|
|
5134
7342
|
const comment = await createComment({
|
|
5135
7343
|
nestId,
|
|
@@ -5153,7 +7361,7 @@ governanceNodeRoutes.post(
|
|
|
5153
7361
|
const nestId = c.req.param("nestId");
|
|
5154
7362
|
const nodeId = c.req.param("nodeId");
|
|
5155
7363
|
const commentId = c.req.param("commentId");
|
|
5156
|
-
const resolvedBy = await
|
|
7364
|
+
const resolvedBy = await getUserEmail4(c);
|
|
5157
7365
|
try {
|
|
5158
7366
|
const comment = await resolveComment({
|
|
5159
7367
|
nestId,
|
|
@@ -5186,7 +7394,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
5186
7394
|
if (currentVersion === 0) {
|
|
5187
7395
|
throw new ValidationError("Node has no versions to review");
|
|
5188
7396
|
}
|
|
5189
|
-
const userEmail = await
|
|
7397
|
+
const userEmail = await getUserEmail4(c);
|
|
5190
7398
|
let request;
|
|
5191
7399
|
try {
|
|
5192
7400
|
request = await submitForReview({
|
|
@@ -5195,7 +7403,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
5195
7403
|
version: currentVersion,
|
|
5196
7404
|
requestedBy: userEmail,
|
|
5197
7405
|
note: body.note,
|
|
5198
|
-
priority: body.priority
|
|
7406
|
+
priority: body.priority,
|
|
7407
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
5199
7408
|
});
|
|
5200
7409
|
} catch (err) {
|
|
5201
7410
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -5221,7 +7430,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
5221
7430
|
const nestId = c.req.param("nestId");
|
|
5222
7431
|
const nodeId = c.req.param("nodeId");
|
|
5223
7432
|
const body = await c.req.json();
|
|
5224
|
-
const userEmail = await
|
|
7433
|
+
const userEmail = await getUserEmail4(c);
|
|
5225
7434
|
const isAdmin = isSuperAdmin(userEmail);
|
|
5226
7435
|
try {
|
|
5227
7436
|
const request = await approve({
|
|
@@ -5230,7 +7439,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
5230
7439
|
version: await getCurrentVersion(nestId, nodeId),
|
|
5231
7440
|
approvedBy: userEmail,
|
|
5232
7441
|
note: body.note,
|
|
5233
|
-
override: body.override && isAdmin
|
|
7442
|
+
override: body.override && isAdmin,
|
|
7443
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
5234
7444
|
});
|
|
5235
7445
|
return c.json({ review: request });
|
|
5236
7446
|
} catch (err) {
|
|
@@ -5244,14 +7454,15 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
5244
7454
|
if (!body.note) {
|
|
5245
7455
|
throw new ValidationError("Rejection note is required");
|
|
5246
7456
|
}
|
|
5247
|
-
const userEmail = await
|
|
7457
|
+
const userEmail = await getUserEmail4(c);
|
|
5248
7458
|
try {
|
|
5249
7459
|
const request = await reject({
|
|
5250
7460
|
nestId,
|
|
5251
7461
|
nodeId,
|
|
5252
7462
|
version: await getCurrentVersion(nestId, nodeId),
|
|
5253
7463
|
rejectedBy: userEmail,
|
|
5254
|
-
note: body.note
|
|
7464
|
+
note: body.note,
|
|
7465
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
5255
7466
|
});
|
|
5256
7467
|
return c.json({ review: request });
|
|
5257
7468
|
} catch (err) {
|
|
@@ -5261,19 +7472,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
5261
7472
|
governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
|
|
5262
7473
|
const nestId = c.req.param("nestId");
|
|
5263
7474
|
const nodeId = c.req.param("nodeId");
|
|
5264
|
-
const userEmail = await
|
|
7475
|
+
const userEmail = await getUserEmail4(c);
|
|
5265
7476
|
return c.json(await canUserAccess(nestId, nodeId, userEmail));
|
|
5266
7477
|
});
|
|
5267
7478
|
governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
|
|
5268
7479
|
const nestId = c.req.param("nestId");
|
|
5269
7480
|
const nodeId = c.req.param("nodeId");
|
|
5270
|
-
const userEmail = await
|
|
7481
|
+
const userEmail = await getUserEmail4(c);
|
|
5271
7482
|
return c.json(await canUserApprove(nestId, nodeId, userEmail));
|
|
5272
7483
|
});
|
|
5273
7484
|
governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
5274
7485
|
const nestId = c.req.param("nestId");
|
|
5275
7486
|
const nodeId = c.req.param("nodeId");
|
|
5276
|
-
const userEmail = await
|
|
7487
|
+
const userEmail = await getUserEmail4(c);
|
|
5277
7488
|
return c.json(await canUserEdit(nestId, nodeId, userEmail));
|
|
5278
7489
|
});
|
|
5279
7490
|
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
@@ -5306,7 +7517,7 @@ governanceNodeRoutes.post(
|
|
|
5306
7517
|
const nodeId = c.req.param("nodeId");
|
|
5307
7518
|
const suggestionId = c.req.param("suggestionId");
|
|
5308
7519
|
const body = await c.req.json().catch(() => ({}));
|
|
5309
|
-
const actor = await
|
|
7520
|
+
const actor = await getUserEmail4(c);
|
|
5310
7521
|
try {
|
|
5311
7522
|
const result = await approveExternalEdit({
|
|
5312
7523
|
nestId,
|
|
@@ -5342,7 +7553,7 @@ governanceNodeRoutes.post(
|
|
|
5342
7553
|
if (!body.reason) {
|
|
5343
7554
|
throw new ValidationError("Rejection reason is required");
|
|
5344
7555
|
}
|
|
5345
|
-
const actor = await
|
|
7556
|
+
const actor = await getUserEmail4(c);
|
|
5346
7557
|
try {
|
|
5347
7558
|
const result = await rejectExternalEdit({
|
|
5348
7559
|
nestId,
|
|
@@ -5363,7 +7574,7 @@ governanceNodeRoutes.post(
|
|
|
5363
7574
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
5364
7575
|
const nestId = c.req.param("nestId");
|
|
5365
7576
|
const nodeId = c.req.param("nodeId");
|
|
5366
|
-
const userEmail = await
|
|
7577
|
+
const userEmail = await getUserEmail4(c);
|
|
5367
7578
|
const request = await cancelReview({
|
|
5368
7579
|
nestId,
|
|
5369
7580
|
nodeId,
|
|
@@ -5371,7 +7582,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
|
5371
7582
|
});
|
|
5372
7583
|
return c.json({ review: request });
|
|
5373
7584
|
});
|
|
5374
|
-
async function
|
|
7585
|
+
async function getUserEmail4(c) {
|
|
5375
7586
|
const userId = c.get("userId");
|
|
5376
7587
|
const db = getDb();
|
|
5377
7588
|
const user = await db.get(
|
|
@@ -5402,12 +7613,12 @@ async function ensureAnonymousUser() {
|
|
|
5402
7613
|
// src/app.ts
|
|
5403
7614
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5404
7615
|
import { fileURLToPath } from "url";
|
|
5405
|
-
import { dirname, join as
|
|
7616
|
+
import { dirname, join as join5, relative as relative2 } from "path";
|
|
5406
7617
|
import { existsSync as existsSync2 } from "fs";
|
|
5407
7618
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
5408
7619
|
var UI_DIR_CANDIDATES = [
|
|
5409
|
-
|
|
5410
|
-
|
|
7620
|
+
join5(HERE, "web3"),
|
|
7621
|
+
join5(process.cwd(), "dist", "web3")
|
|
5411
7622
|
];
|
|
5412
7623
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
5413
7624
|
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
@@ -5444,7 +7655,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
5444
7655
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
5445
7656
|
});
|
|
5446
7657
|
function createApp() {
|
|
5447
|
-
const app = new
|
|
7658
|
+
const app = new Hono16({ router: new LinearRouter() });
|
|
5448
7659
|
const corsOrigins = config.CORS_ORIGINS;
|
|
5449
7660
|
app.use(
|
|
5450
7661
|
"*",
|
|
@@ -5465,6 +7676,22 @@ function createApp() {
|
|
|
5465
7676
|
}
|
|
5466
7677
|
return next();
|
|
5467
7678
|
});
|
|
7679
|
+
app.use("*", async (c, next) => {
|
|
7680
|
+
const path = c.req.path;
|
|
7681
|
+
const traced = /^\/(nests|auth|admin|stats|license|index|mcp)(\/|$)/.test(path) && !path.endsWith("/mcp");
|
|
7682
|
+
const started = Date.now();
|
|
7683
|
+
await next();
|
|
7684
|
+
if (!traced) return;
|
|
7685
|
+
void logTraceEvent({
|
|
7686
|
+
kind: "api",
|
|
7687
|
+
method: c.req.method,
|
|
7688
|
+
path,
|
|
7689
|
+
nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
|
|
7690
|
+
userId: c.get("userId") ?? null,
|
|
7691
|
+
status: c.res.status,
|
|
7692
|
+
durationMs: Date.now() - started
|
|
7693
|
+
});
|
|
7694
|
+
});
|
|
5468
7695
|
app.get(
|
|
5469
7696
|
"/health",
|
|
5470
7697
|
(c) => c.json({
|
|
@@ -5474,6 +7701,14 @@ function createApp() {
|
|
|
5474
7701
|
auth_mode: config.AUTH_MODE,
|
|
5475
7702
|
logo_url: config.LOGO_URL,
|
|
5476
7703
|
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
7704
|
+
// Public feature flags the SPA needs before auth to decide what to
|
|
7705
|
+
// render. Mirrors the server-side route gate so UI and API agree:
|
|
7706
|
+
// flag off → the plane's routes 404 AND its UI stays hidden.
|
|
7707
|
+
workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
|
|
7708
|
+
// Sub-agent runs gate its own recursion surface separately (env-only,
|
|
7709
|
+
// must clear a security review — NOT a UI toggle). Exposed read-only so
|
|
7710
|
+
// the Runs page shows the "spawn sub-agent" option only when it's live.
|
|
7711
|
+
subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
|
|
5477
7712
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
5478
7713
|
})
|
|
5479
7714
|
);
|
|
@@ -5564,7 +7799,13 @@ function createApp() {
|
|
|
5564
7799
|
logo_url: config.LOGO_URL,
|
|
5565
7800
|
telemetry_enabled: config.TELEMETRY_ENABLED,
|
|
5566
7801
|
public_base_url: config.PUBLIC_BASE_URL,
|
|
5567
|
-
max_body_bytes: config.MAX_BODY_BYTES
|
|
7802
|
+
max_body_bytes: config.MAX_BODY_BYTES,
|
|
7803
|
+
workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
|
|
7804
|
+
subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
|
|
7805
|
+
slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
|
|
7806
|
+
smtp_url: config.SMTP_URL ?? "",
|
|
7807
|
+
notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
|
|
7808
|
+
notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
|
|
5568
7809
|
});
|
|
5569
7810
|
app.get("/admin/settings", async (c) => {
|
|
5570
7811
|
if (!await adminSettingsAllowed(c))
|
|
@@ -5599,6 +7840,49 @@ function createApp() {
|
|
|
5599
7840
|
if ("telemetry_enabled" in body) {
|
|
5600
7841
|
pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
|
|
5601
7842
|
}
|
|
7843
|
+
if ("workflow_plane_enabled" in body) {
|
|
7844
|
+
pending.push({
|
|
7845
|
+
name: "FEATURE_WORKFLOW_PLANE",
|
|
7846
|
+
value: body.workflow_plane_enabled ? "true" : null
|
|
7847
|
+
});
|
|
7848
|
+
}
|
|
7849
|
+
if ("subagent_runs_enabled" in body) {
|
|
7850
|
+
const planeOn = "workflow_plane_enabled" in body ? !!body.workflow_plane_enabled : config.FEATURE_WORKFLOW_PLANE;
|
|
7851
|
+
if (body.subagent_runs_enabled && !planeOn) {
|
|
7852
|
+
errors.push(
|
|
7853
|
+
"subagent_runs_enabled requires workflow_plane_enabled to be on"
|
|
7854
|
+
);
|
|
7855
|
+
} else {
|
|
7856
|
+
pending.push({
|
|
7857
|
+
name: "FEATURE_SUBAGENT_RUNS",
|
|
7858
|
+
value: body.subagent_runs_enabled ? "true" : null
|
|
7859
|
+
});
|
|
7860
|
+
}
|
|
7861
|
+
}
|
|
7862
|
+
if ("slack_webhook_url" in body) {
|
|
7863
|
+
const v = String(body.slack_webhook_url ?? "").trim();
|
|
7864
|
+
if (v && !/^https:\/\//i.test(v))
|
|
7865
|
+
errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
|
|
7866
|
+
else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
|
|
7867
|
+
}
|
|
7868
|
+
if ("smtp_url" in body) {
|
|
7869
|
+
const v = String(body.smtp_url ?? "").trim();
|
|
7870
|
+
if (v && !/^smtps?:\/\//i.test(v))
|
|
7871
|
+
errors.push("smtp_url must be an smtp:// or smtps:// URL (or empty to disable)");
|
|
7872
|
+
else pending.push({ name: "SMTP_URL", value: v || null });
|
|
7873
|
+
}
|
|
7874
|
+
if ("notify_email_from" in body) {
|
|
7875
|
+
const v = String(body.notify_email_from ?? "").trim();
|
|
7876
|
+
if (v && !isEmailish(v))
|
|
7877
|
+
errors.push("notify_email_from must be a plain email address");
|
|
7878
|
+
else pending.push({ name: "NOTIFY_EMAIL_FROM", value: v || null });
|
|
7879
|
+
}
|
|
7880
|
+
if ("notify_email_to" in body) {
|
|
7881
|
+
const v = String(body.notify_email_to ?? "").trim();
|
|
7882
|
+
if (v && !isEmailListish(v))
|
|
7883
|
+
errors.push("notify_email_to must be a comma-separated list of email addresses");
|
|
7884
|
+
else pending.push({ name: "NOTIFY_EMAIL_TO", value: v || null });
|
|
7885
|
+
}
|
|
5602
7886
|
if ("max_body_bytes" in body) {
|
|
5603
7887
|
const n = Number(body.max_body_bytes);
|
|
5604
7888
|
if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
|
|
@@ -5624,6 +7908,29 @@ function createApp() {
|
|
|
5624
7908
|
}
|
|
5625
7909
|
return c.json({ settings: currentServerSettings() });
|
|
5626
7910
|
});
|
|
7911
|
+
app.use("/admin/trace", flexAuthMiddleware);
|
|
7912
|
+
const traceAllowed = async (c) => {
|
|
7913
|
+
if (config.AUTH_MODE === "open") return true;
|
|
7914
|
+
const userId = c.get("userId");
|
|
7915
|
+
if (await isLicenseAdminUserId(userId)) return true;
|
|
7916
|
+
return isSuperAdmin(await resolveCallerEmail(userId));
|
|
7917
|
+
};
|
|
7918
|
+
app.get("/admin/trace", async (c) => {
|
|
7919
|
+
if (!await traceAllowed(c))
|
|
7920
|
+
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
7921
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
7922
|
+
const offsetRaw = Number(c.req.query("offset"));
|
|
7923
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
|
|
7924
|
+
const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
|
|
7925
|
+
const { events, total } = await listTraceEvents({
|
|
7926
|
+
limit,
|
|
7927
|
+
offset,
|
|
7928
|
+
kind: c.req.query("kind"),
|
|
7929
|
+
nestId: c.req.query("nest") || void 0,
|
|
7930
|
+
user: c.req.query("user") || void 0
|
|
7931
|
+
});
|
|
7932
|
+
return c.json({ count: events.length, total, limit, offset, events });
|
|
7933
|
+
});
|
|
5627
7934
|
app.use("/stats", flexAuthMiddleware);
|
|
5628
7935
|
app.get("/stats", async (c) => {
|
|
5629
7936
|
const db = getDb();
|
|
@@ -5650,7 +7957,14 @@ function createApp() {
|
|
|
5650
7957
|
users: usersRow.c
|
|
5651
7958
|
});
|
|
5652
7959
|
});
|
|
5653
|
-
|
|
7960
|
+
app.use("/runs/*", flexAuthMiddleware);
|
|
7961
|
+
app.route("/runs", runDetailRoutes);
|
|
7962
|
+
app.route("/llms.txt", llmsTxtRoutes);
|
|
7963
|
+
app.use("/index", flexAuthMiddleware);
|
|
7964
|
+
app.route("/index", nestIndexRoutes);
|
|
7965
|
+
app.use("/mcp", flexAuthMiddleware);
|
|
7966
|
+
app.route("/mcp", serverMcpRoutes);
|
|
7967
|
+
const nestsApp = new Hono16();
|
|
5654
7968
|
nestsApp.use("*", flexAuthMiddleware);
|
|
5655
7969
|
nestsApp.use("*", async (c, next) => {
|
|
5656
7970
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
@@ -5698,8 +8012,27 @@ function createApp() {
|
|
|
5698
8012
|
return next();
|
|
5699
8013
|
}
|
|
5700
8014
|
const permission = await resolveNestPermission(nestId, userId);
|
|
8015
|
+
const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
|
|
8016
|
+
let grantRole = null;
|
|
8017
|
+
let grantNestVisible = false;
|
|
5701
8018
|
if (permission === "none") {
|
|
5702
|
-
|
|
8019
|
+
if (parts[1] === "nodes" && parts.length >= 3) {
|
|
8020
|
+
let targetTail = parts.slice(2).join("/");
|
|
8021
|
+
try {
|
|
8022
|
+
targetTail = decodeURIComponent(targetTail);
|
|
8023
|
+
} catch {
|
|
8024
|
+
}
|
|
8025
|
+
grantRole = await resolveNodeGrant(nestId, userId, targetTail);
|
|
8026
|
+
} else if (parts.length === 1 && c.req.method === "GET") {
|
|
8027
|
+
grantNestVisible = await hasAnyGrant(nestId, userId);
|
|
8028
|
+
} else if (parts[1] === "nodes" && parts.length === 2 && c.req.method === "GET") {
|
|
8029
|
+
grantNestVisible = await hasAnyGrant(nestId, userId);
|
|
8030
|
+
} else if (isReadQuery) {
|
|
8031
|
+
grantNestVisible = await hasAnyGrant(nestId, userId);
|
|
8032
|
+
}
|
|
8033
|
+
if (!grantRole && !grantNestVisible) {
|
|
8034
|
+
return c.json({ error: "Nest not found" }, 404);
|
|
8035
|
+
}
|
|
5703
8036
|
}
|
|
5704
8037
|
let required = "read";
|
|
5705
8038
|
const path = c.req.path;
|
|
@@ -5720,7 +8053,7 @@ function createApp() {
|
|
|
5720
8053
|
required = "admin";
|
|
5721
8054
|
} else if (resource === "collaborators") {
|
|
5722
8055
|
required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
|
|
5723
|
-
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction) {
|
|
8056
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery) {
|
|
5724
8057
|
required = "write";
|
|
5725
8058
|
}
|
|
5726
8059
|
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
@@ -5746,7 +8079,9 @@ function createApp() {
|
|
|
5746
8079
|
);
|
|
5747
8080
|
}
|
|
5748
8081
|
}
|
|
5749
|
-
|
|
8082
|
+
const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
|
|
8083
|
+
const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
|
|
8084
|
+
if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
|
|
5750
8085
|
return c.json(
|
|
5751
8086
|
{
|
|
5752
8087
|
error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
|
|
@@ -5756,16 +8091,30 @@ function createApp() {
|
|
|
5756
8091
|
403
|
|
5757
8092
|
);
|
|
5758
8093
|
}
|
|
5759
|
-
c.set(
|
|
8094
|
+
c.set(
|
|
8095
|
+
"nestPermission",
|
|
8096
|
+
permission === "none" && grantRole ? grantRole : permission
|
|
8097
|
+
);
|
|
8098
|
+
return next();
|
|
8099
|
+
});
|
|
8100
|
+
nestsApp.use("/:nestId/nodes/:nodeId{.+}", async (c, next) => {
|
|
8101
|
+
assertSafeNodeId(c.req.param("nodeId"));
|
|
5760
8102
|
return next();
|
|
5761
8103
|
});
|
|
5762
8104
|
nestsApp.route("/", nestRoutes);
|
|
5763
8105
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
5764
|
-
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5765
8106
|
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
8107
|
+
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5766
8108
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
5767
8109
|
nestsApp.route("/:nestId", queryRoutes);
|
|
5768
8110
|
nestsApp.route("/:nestId", sharingRoutes);
|
|
8111
|
+
nestsApp.route("/:nestId/grants", grantRoutes);
|
|
8112
|
+
nestsApp.route("/:nestId/edge-types", edgeTypeRoutes);
|
|
8113
|
+
nestsApp.route("/:nestId/edges", edgeRoutes);
|
|
8114
|
+
nestsApp.route("/:nestId/run", runTriggerRoutes);
|
|
8115
|
+
nestsApp.route("/:nestId/runs", runListRoutes);
|
|
8116
|
+
nestsApp.route("/:nestId/definitions", definitionRoutes);
|
|
8117
|
+
nestsApp.route("/:nestId/assets", assetRoutes);
|
|
5769
8118
|
nestsApp.route("/:nestId/mcp", mcpRoutes);
|
|
5770
8119
|
app.route("/nests", nestsApp);
|
|
5771
8120
|
app.use("/assets/*", serveStatic({ root: UI_DIR_REL }));
|