@promptowl/contextnest-community 1.9.0 → 1.10.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 +29 -2
- package/dist/{chunk-TIO5XOFD.js → chunk-7PQREKSM.js} +1 -1
- package/dist/{chunk-DPHV6Q26.js → chunk-BC6KFUZH.js} +205 -22
- package/dist/{chunk-BYS4HDME.js → chunk-KFGZIECB.js} +73 -26
- package/dist/{chunk-HRQWNRVI.js → chunk-UHUU3VAK.js} +1 -1
- package/dist/{chunk-VC3QNHHB.js → chunk-ZM4F7MY7.js} +685 -6
- package/dist/{client-GI74NPIW.js → client-VZLX4THW.js} +1 -1
- package/dist/{grants-service-MDLRPMZR.js → grants-service-6H4WUA2F.js} +2 -2
- package/dist/index.js +1953 -521
- package/dist/{migrations.postgres-ORSV7UFJ.js → migrations.postgres-4XYY3CTF.js} +100 -2
- package/dist/{review-service-A3XFQTJH.js → review-service-VLSUT6KK.js} +5 -4
- package/dist/{stewardship-service-3KCVVXCW.js → stewardship-service-D5PDWXBR.js} +2 -2
- package/dist/{version-service-6J4OWQL6.js → version-service-A2YAKYQW.js} +2 -2
- package/dist/web3/assets/index-B2PkvzmY.js +1007 -0
- package/dist/web3/assets/index-C-tX933G.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -2
- package/dist/web3/assets/index-CHavHPIT.css +0 -1
- package/dist/web3/assets/index-DyvOBH92.js +0 -934
package/dist/index.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
addWatcher,
|
|
3
4
|
approve,
|
|
5
|
+
canReadNode,
|
|
4
6
|
cancelReview,
|
|
7
|
+
dispatchEvent,
|
|
8
|
+
edgeTypeRoutes,
|
|
9
|
+
edgeTypeToResponse,
|
|
10
|
+
envRoutes,
|
|
11
|
+
envValues,
|
|
12
|
+
filterAccessible,
|
|
5
13
|
getPendingReview,
|
|
6
14
|
getReviewHistory,
|
|
7
15
|
getReviewQueue,
|
|
16
|
+
isSafeConnectorUrl,
|
|
17
|
+
listNotifications,
|
|
18
|
+
listWatchers,
|
|
19
|
+
markNotificationsRead,
|
|
8
20
|
notifyNestEvent,
|
|
21
|
+
parseConditionSchema,
|
|
9
22
|
reject,
|
|
23
|
+
removeWatcher,
|
|
24
|
+
requireWorkflowPlane,
|
|
25
|
+
resolveCallerEmail,
|
|
26
|
+
safeJson,
|
|
10
27
|
safePublishDocument,
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
seedDefaultEdgeTypes,
|
|
29
|
+
submitForReview,
|
|
30
|
+
verifySmtp
|
|
31
|
+
} from "./chunk-ZM4F7MY7.js";
|
|
13
32
|
import {
|
|
14
33
|
createGrant,
|
|
15
34
|
deleteGrant,
|
|
@@ -18,7 +37,7 @@ import {
|
|
|
18
37
|
listGrants,
|
|
19
38
|
listUserGrants,
|
|
20
39
|
resolveNodeGrant
|
|
21
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-7PQREKSM.js";
|
|
22
41
|
import {
|
|
23
42
|
generateApiKey,
|
|
24
43
|
getKeyPrefix,
|
|
@@ -38,7 +57,7 @@ import {
|
|
|
38
57
|
getDisplayStatus,
|
|
39
58
|
getVersions,
|
|
40
59
|
setApprovedVersion
|
|
41
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-UHUU3VAK.js";
|
|
42
61
|
import {
|
|
43
62
|
canCreateInNest,
|
|
44
63
|
canManageStewards,
|
|
@@ -72,10 +91,12 @@ import {
|
|
|
72
91
|
listSharedNests,
|
|
73
92
|
listStewards,
|
|
74
93
|
loadAccessConfig,
|
|
94
|
+
loadServerSettings,
|
|
75
95
|
nestAllowsSelfApprove,
|
|
76
96
|
nestName,
|
|
77
97
|
nestStorageRoot,
|
|
78
98
|
permissionLevel,
|
|
99
|
+
persistSetting,
|
|
79
100
|
removeSteward,
|
|
80
101
|
renameNest,
|
|
81
102
|
resolveNestPath,
|
|
@@ -91,9 +112,8 @@ import {
|
|
|
91
112
|
trackEvent,
|
|
92
113
|
uniqueNestName,
|
|
93
114
|
updateSteward,
|
|
94
|
-
upsertEnvVar,
|
|
95
115
|
validateLicense
|
|
96
|
-
} from "./chunk-
|
|
116
|
+
} from "./chunk-KFGZIECB.js";
|
|
97
117
|
import {
|
|
98
118
|
AppError,
|
|
99
119
|
ConflictError,
|
|
@@ -112,7 +132,7 @@ import {
|
|
|
112
132
|
initDb,
|
|
113
133
|
isEmailListish,
|
|
114
134
|
isEmailish
|
|
115
|
-
} from "./chunk-
|
|
135
|
+
} from "./chunk-BC6KFUZH.js";
|
|
116
136
|
import {
|
|
117
137
|
ANON_EMAIL,
|
|
118
138
|
ANON_USER_ID
|
|
@@ -122,7 +142,7 @@ import {
|
|
|
122
142
|
import { serve } from "@hono/node-server";
|
|
123
143
|
|
|
124
144
|
// src/app.ts
|
|
125
|
-
import { Hono as
|
|
145
|
+
import { Hono as Hono18 } from "hono";
|
|
126
146
|
import { LinearRouter } from "hono/router/linear-router";
|
|
127
147
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
128
148
|
import { cors } from "hono/cors";
|
|
@@ -280,6 +300,14 @@ function recordFailure(key, cfg) {
|
|
|
280
300
|
function clear(key) {
|
|
281
301
|
buckets.delete(key);
|
|
282
302
|
}
|
|
303
|
+
function sweepStale(maxAgeMs = 10 * 6e4) {
|
|
304
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
305
|
+
for (const [key, bucket] of buckets.entries()) {
|
|
306
|
+
if (!bucket.hits.length || bucket.hits[bucket.hits.length - 1] < cutoff) {
|
|
307
|
+
buckets.delete(key);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
283
311
|
|
|
284
312
|
// src/auth/sso-token.ts
|
|
285
313
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
@@ -450,6 +478,14 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
|
|
|
450
478
|
}
|
|
451
479
|
var authRoutes = new Hono();
|
|
452
480
|
authRoutes.post("/register", async (c) => {
|
|
481
|
+
if (config.MANUAL_SIGN_IN !== "open") {
|
|
482
|
+
return c.json(
|
|
483
|
+
{
|
|
484
|
+
error: "Self-registration is disabled on this server. If you were invited, use \u201CSet your password\u201D to activate your account. Otherwise ask your admin for an invite."
|
|
485
|
+
},
|
|
486
|
+
403
|
|
487
|
+
);
|
|
488
|
+
}
|
|
453
489
|
const body = await c.req.json();
|
|
454
490
|
if (!body.email || !body.password) {
|
|
455
491
|
throw new ValidationError("email and password are required");
|
|
@@ -465,25 +501,24 @@ authRoutes.post("/register", async (c) => {
|
|
|
465
501
|
"SELECT id, is_invited FROM users WHERE LOWER(email) = ?",
|
|
466
502
|
[email]
|
|
467
503
|
);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
} else if (existing) {
|
|
504
|
+
if (existing) {
|
|
505
|
+
if (existing.is_invited === 1) {
|
|
506
|
+
return c.json(
|
|
507
|
+
{
|
|
508
|
+
error: "You've been invited to this server. Ask your admin for your temporary password, then sign in and change it \u2014 you can't set it here."
|
|
509
|
+
},
|
|
510
|
+
409
|
|
511
|
+
);
|
|
512
|
+
}
|
|
478
513
|
throw new ValidationError("Email already registered");
|
|
479
|
-
} else {
|
|
480
|
-
userId = uuid();
|
|
481
|
-
await db.run(
|
|
482
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
|
|
483
|
-
[userId, email, body.name || null, passwordHash]
|
|
484
|
-
);
|
|
485
|
-
trackEvent("user.register", { userId, email });
|
|
486
514
|
}
|
|
515
|
+
const userId = uuid();
|
|
516
|
+
const passwordHash = await hashPassword(body.password);
|
|
517
|
+
await db.run(
|
|
518
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
|
|
519
|
+
[userId, email, body.name || null, passwordHash]
|
|
520
|
+
);
|
|
521
|
+
trackEvent("user.register", { userId, email });
|
|
487
522
|
const sessionId = await createSession(userId, c.req.header("User-Agent"));
|
|
488
523
|
setSessionCookie(c, sessionId);
|
|
489
524
|
return c.json(
|
|
@@ -499,6 +534,14 @@ authRoutes.post("/register", async (c) => {
|
|
|
499
534
|
);
|
|
500
535
|
});
|
|
501
536
|
authRoutes.post("/login", async (c) => {
|
|
537
|
+
if (config.MANUAL_SIGN_IN === "disabled") {
|
|
538
|
+
return c.json(
|
|
539
|
+
{
|
|
540
|
+
error: "Email & password sign-in is disabled on this server. Sign in with PromptOwl, or contact your admin."
|
|
541
|
+
},
|
|
542
|
+
403
|
|
543
|
+
);
|
|
544
|
+
}
|
|
502
545
|
const body = await c.req.json();
|
|
503
546
|
if (!body.email || !body.password) {
|
|
504
547
|
throw new ValidationError("email and password are required");
|
|
@@ -654,7 +697,7 @@ authRoutes.post("/device", async (c) => {
|
|
|
654
697
|
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
655
698
|
const res = await fetch(`${promptowlUrl}/api/auth/device`, {
|
|
656
699
|
method: "POST",
|
|
657
|
-
headers:
|
|
700
|
+
headers: config.PROMPTOWL_FETCH_HEADERS,
|
|
658
701
|
body: JSON.stringify({
|
|
659
702
|
deviceName: body.deviceName || "ContextNest Community",
|
|
660
703
|
deviceType: body.deviceType || "webapp"
|
|
@@ -677,7 +720,8 @@ authRoutes.get("/device/poll", async (c) => {
|
|
|
677
720
|
}
|
|
678
721
|
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
679
722
|
const res = await fetch(
|
|
680
|
-
`${promptowlUrl}/api/auth/device/poll?code=${encodeURIComponent(code)}&client_secret=${encodeURIComponent(clientSecret)}
|
|
723
|
+
`${promptowlUrl}/api/auth/device/poll?code=${encodeURIComponent(code)}&client_secret=${encodeURIComponent(clientSecret)}`,
|
|
724
|
+
{ headers: config.PROMPTOWL_FETCH_HEADERS }
|
|
681
725
|
);
|
|
682
726
|
const data = await res.json();
|
|
683
727
|
return c.json(data, res.status);
|
|
@@ -692,7 +736,7 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
692
736
|
}
|
|
693
737
|
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
694
738
|
const meRes = await fetch(`${promptowlUrl}/api/user/me`, {
|
|
695
|
-
headers: { Authorization: `Bearer ${body.token}` }
|
|
739
|
+
headers: { ...config.PROMPTOWL_FETCH_HEADERS, Authorization: `Bearer ${body.token}` }
|
|
696
740
|
});
|
|
697
741
|
if (!meRes.ok) {
|
|
698
742
|
return c.json({ error: "Invalid or expired PromptOwl token" }, 401);
|
|
@@ -1051,7 +1095,12 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1051
1095
|
import { Hono as Hono2 } from "hono";
|
|
1052
1096
|
|
|
1053
1097
|
// src/nodes/service.ts
|
|
1054
|
-
import {
|
|
1098
|
+
import {
|
|
1099
|
+
serializeDocument,
|
|
1100
|
+
parseDocument as parseDocument2
|
|
1101
|
+
} from "@promptowl/contextnest-engine";
|
|
1102
|
+
import { rename, mkdir } from "fs/promises";
|
|
1103
|
+
import { dirname, join as join2 } from "path";
|
|
1055
1104
|
|
|
1056
1105
|
// src/governance/tag-index-service.ts
|
|
1057
1106
|
function normalizeTag(raw) {
|
|
@@ -1086,45 +1135,6 @@ async function removeNodeFromTagIndex(nestId, nodeId) {
|
|
|
1086
1135
|
);
|
|
1087
1136
|
}
|
|
1088
1137
|
|
|
1089
|
-
// src/governance/access-guard.ts
|
|
1090
|
-
async function resolveCallerEmail(userId) {
|
|
1091
|
-
if (!userId) return "admin@localhost";
|
|
1092
|
-
const db = getDb();
|
|
1093
|
-
const row = await db.get(
|
|
1094
|
-
"SELECT email FROM users WHERE id = ?",
|
|
1095
|
-
[userId]
|
|
1096
|
-
);
|
|
1097
|
-
return row?.email || "admin@localhost";
|
|
1098
|
-
}
|
|
1099
|
-
async function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
1100
|
-
if (await isPublicReader(nestId, userId)) {
|
|
1101
|
-
return await getApprovedVersion(nestId, nodeId) !== null;
|
|
1102
|
-
}
|
|
1103
|
-
if (!await isStewardshipEnabled(nestId)) return true;
|
|
1104
|
-
if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
|
|
1105
|
-
return await resolveNodeGrant(nestId, userId, nodeId) !== null;
|
|
1106
|
-
}
|
|
1107
|
-
async function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
1108
|
-
if (await isPublicReader(nestId, userId)) {
|
|
1109
|
-
const filtered = [];
|
|
1110
|
-
for (const n of nodes) {
|
|
1111
|
-
if (await getApprovedVersion(nestId, n.id) !== null) {
|
|
1112
|
-
filtered.push(n);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
return filtered;
|
|
1116
|
-
}
|
|
1117
|
-
if (!await isStewardshipEnabled(nestId)) return nodes;
|
|
1118
|
-
const grants = await listUserGrants(nestId, userId);
|
|
1119
|
-
const accessible = [];
|
|
1120
|
-
for (const n of nodes) {
|
|
1121
|
-
if ((await canUserAccess(nestId, n.id, userEmail)).allowed || grantCoversNode(grants, n.id)) {
|
|
1122
|
-
accessible.push(n);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
return accessible;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
1138
|
// src/governance/external-edit-service.ts
|
|
1129
1139
|
import { readFile } from "fs/promises";
|
|
1130
1140
|
import { join } from "path";
|
|
@@ -1308,7 +1318,7 @@ async function approveExternalEdit(input) {
|
|
|
1308
1318
|
const node = await storage.readDocument(input.documentId);
|
|
1309
1319
|
const versionNum = result.versionEntry.version;
|
|
1310
1320
|
const tags = node.frontmatter.tags || [];
|
|
1311
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1321
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-A2YAKYQW.js");
|
|
1312
1322
|
await createVersion2({
|
|
1313
1323
|
nestId: input.nestId,
|
|
1314
1324
|
nodeId: input.documentId,
|
|
@@ -1382,11 +1392,44 @@ function startDriftScanner(intervalMs = 3e4) {
|
|
|
1382
1392
|
// src/nodes/service.ts
|
|
1383
1393
|
async function userIdFromEmail(email) {
|
|
1384
1394
|
const db = getDb();
|
|
1385
|
-
const row = await db.get(
|
|
1395
|
+
const row = await db.get(
|
|
1396
|
+
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
1397
|
+
[email]
|
|
1398
|
+
);
|
|
1386
1399
|
return row?.id ?? ANON_USER_ID;
|
|
1387
1400
|
}
|
|
1388
1401
|
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1402
|
+
var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1403
|
+
function folderToSegments(folder) {
|
|
1404
|
+
const segments = folder.split("/").map(slugify).filter(Boolean);
|
|
1405
|
+
if (segments.length > 8) {
|
|
1406
|
+
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1407
|
+
}
|
|
1408
|
+
if (segments.some((seg) => seg.length > 100)) {
|
|
1409
|
+
throw new ValidationError(
|
|
1410
|
+
"each folder or title segment must be at most 100 characters"
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
return segments;
|
|
1414
|
+
}
|
|
1389
1415
|
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1416
|
+
var nodeWriteChains = /* @__PURE__ */ new Map();
|
|
1417
|
+
function withNodeWriteLock(key, fn) {
|
|
1418
|
+
const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
|
|
1419
|
+
});
|
|
1420
|
+
const run = prev.then(fn);
|
|
1421
|
+
nodeWriteChains.set(key, run);
|
|
1422
|
+
void run.catch(() => {
|
|
1423
|
+
}).finally(() => {
|
|
1424
|
+
if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
|
|
1425
|
+
});
|
|
1426
|
+
return run;
|
|
1427
|
+
}
|
|
1428
|
+
function toSafeError(err, context, message) {
|
|
1429
|
+
if (err instanceof AppError) return err;
|
|
1430
|
+
console.error(`${context}:`, err);
|
|
1431
|
+
return new AppError(500, message);
|
|
1432
|
+
}
|
|
1390
1433
|
function bodyOnly(nodeId, raw) {
|
|
1391
1434
|
try {
|
|
1392
1435
|
return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
|
|
@@ -1449,10 +1492,18 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
1449
1492
|
const approved = await getApprovedVersion(nestId, doc.id);
|
|
1450
1493
|
if (approved != null) {
|
|
1451
1494
|
try {
|
|
1452
|
-
const raw = await versionManager.reconstructVersion(
|
|
1495
|
+
const raw = await versionManager.reconstructVersion(
|
|
1496
|
+
doc.id,
|
|
1497
|
+
approved
|
|
1498
|
+
);
|
|
1453
1499
|
r.content = bodyOnly(doc.id, raw);
|
|
1454
1500
|
} catch (err) {
|
|
1455
|
-
console.error(
|
|
1501
|
+
console.error(
|
|
1502
|
+
"reconstructVersion failed (list)",
|
|
1503
|
+
doc.id,
|
|
1504
|
+
approved,
|
|
1505
|
+
err
|
|
1506
|
+
);
|
|
1456
1507
|
r.content = "";
|
|
1457
1508
|
}
|
|
1458
1509
|
r.version = approved;
|
|
@@ -1482,16 +1533,14 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
|
1482
1533
|
}
|
|
1483
1534
|
async function createNode(nestId, input, userEmail) {
|
|
1484
1535
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1485
|
-
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1486
1536
|
const slug = slugify(input.title);
|
|
1487
1537
|
let id = input.id;
|
|
1488
1538
|
if (!id) {
|
|
1489
|
-
const folderSegments = (input.folder ?? "")
|
|
1490
|
-
if (folderSegments.length > 8) {
|
|
1491
|
-
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1492
|
-
}
|
|
1539
|
+
const folderSegments = folderToSegments(input.folder ?? "");
|
|
1493
1540
|
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
1494
|
-
throw new ValidationError(
|
|
1541
|
+
throw new ValidationError(
|
|
1542
|
+
"each folder or title segment must be at most 100 characters"
|
|
1543
|
+
);
|
|
1495
1544
|
}
|
|
1496
1545
|
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
1497
1546
|
}
|
|
@@ -1499,7 +1548,9 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1499
1548
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1500
1549
|
if (input.schedule !== void 0 && !isRunnableType(input.type)) {
|
|
1501
1550
|
throw new ValidationError(
|
|
1502
|
-
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
|
|
1551
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
|
|
1552
|
+
", "
|
|
1553
|
+
)})`
|
|
1503
1554
|
);
|
|
1504
1555
|
}
|
|
1505
1556
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
@@ -1612,7 +1663,11 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1612
1663
|
version = result.node.frontmatter.version || version;
|
|
1613
1664
|
content = result.node.body || content;
|
|
1614
1665
|
} catch (err) {
|
|
1615
|
-
console.error(
|
|
1666
|
+
console.error(
|
|
1667
|
+
"safePublishDocument failed (import register)",
|
|
1668
|
+
nodeId,
|
|
1669
|
+
err
|
|
1670
|
+
);
|
|
1616
1671
|
}
|
|
1617
1672
|
await createVersion({
|
|
1618
1673
|
nestId,
|
|
@@ -1632,124 +1687,270 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1632
1687
|
return registered;
|
|
1633
1688
|
}
|
|
1634
1689
|
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
1635
|
-
|
|
1690
|
+
return withNodeWriteLock(`${nestId}::${nodeId}`, async () => {
|
|
1691
|
+
try {
|
|
1692
|
+
const { storage, versions: versionManager } = await engineCache.get(
|
|
1693
|
+
nestId
|
|
1694
|
+
);
|
|
1695
|
+
let node;
|
|
1696
|
+
try {
|
|
1697
|
+
node = await storage.readDocument(nodeId);
|
|
1698
|
+
} catch {
|
|
1699
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1700
|
+
}
|
|
1701
|
+
if (patch.content !== void 0) {
|
|
1702
|
+
node = { ...node, body: patch.content };
|
|
1703
|
+
}
|
|
1704
|
+
if (patch.append) {
|
|
1705
|
+
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
1706
|
+
}
|
|
1707
|
+
if (patch.tags) {
|
|
1708
|
+
const newTags = patch.tags.map(normalizeTag2);
|
|
1709
|
+
const merged = [
|
|
1710
|
+
.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])
|
|
1711
|
+
];
|
|
1712
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1713
|
+
}
|
|
1714
|
+
if (patch.status) {
|
|
1715
|
+
node = {
|
|
1716
|
+
...node,
|
|
1717
|
+
frontmatter: { ...node.frontmatter, status: patch.status }
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
if (patch.title) {
|
|
1721
|
+
node = {
|
|
1722
|
+
...node,
|
|
1723
|
+
frontmatter: { ...node.frontmatter, title: patch.title }
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
if (patch.schedule !== void 0) {
|
|
1727
|
+
if (!isRunnableType(node.frontmatter.type)) {
|
|
1728
|
+
throw new ValidationError(
|
|
1729
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
|
|
1730
|
+
", "
|
|
1731
|
+
)})`
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
1735
|
+
if (patch.schedule === "") delete metadata.schedule;
|
|
1736
|
+
else metadata.schedule = patch.schedule;
|
|
1737
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
1738
|
+
}
|
|
1739
|
+
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1740
|
+
const currentTags = node.frontmatter.tags || [];
|
|
1741
|
+
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
1742
|
+
throw new LockedError(
|
|
1743
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
let responseVersion;
|
|
1747
|
+
if (hasStewards) {
|
|
1748
|
+
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
1749
|
+
const newVersion = currentVersion + 1;
|
|
1750
|
+
node = {
|
|
1751
|
+
...node,
|
|
1752
|
+
frontmatter: {
|
|
1753
|
+
...node.frontmatter,
|
|
1754
|
+
version: newVersion,
|
|
1755
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1756
|
+
// Drop stale published-state checksum so the next verified read
|
|
1757
|
+
// doesn't flag this write as external drift.
|
|
1758
|
+
checksum: void 0
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1762
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1763
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1764
|
+
try {
|
|
1765
|
+
await versionManager.createVersion(node, userEmail, {
|
|
1766
|
+
note: patch.changeNote
|
|
1767
|
+
});
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
console.error(
|
|
1770
|
+
"VersionManager.createVersion failed (node patch)",
|
|
1771
|
+
err
|
|
1772
|
+
);
|
|
1773
|
+
throw err;
|
|
1774
|
+
}
|
|
1775
|
+
await createVersion({
|
|
1776
|
+
nestId,
|
|
1777
|
+
nodeId,
|
|
1778
|
+
version: newVersion,
|
|
1779
|
+
content: node.body || "",
|
|
1780
|
+
author: userEmail,
|
|
1781
|
+
status: "draft",
|
|
1782
|
+
tags: currentTags,
|
|
1783
|
+
changeNote: patch.changeNote
|
|
1784
|
+
});
|
|
1785
|
+
responseVersion = newVersion;
|
|
1786
|
+
} else {
|
|
1787
|
+
node = {
|
|
1788
|
+
...node,
|
|
1789
|
+
frontmatter: {
|
|
1790
|
+
...node.frontmatter,
|
|
1791
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1792
|
+
checksum: void 0
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1796
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1797
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1798
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1799
|
+
try {
|
|
1800
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1801
|
+
editedBy: userEmail,
|
|
1802
|
+
note: patch.changeNote || "Auto-published on edit (no stewards)"
|
|
1803
|
+
});
|
|
1804
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
1805
|
+
node = result.node;
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
console.error(
|
|
1808
|
+
"publishDocument failed (node patch auto-publish)",
|
|
1809
|
+
err
|
|
1810
|
+
);
|
|
1811
|
+
throw err;
|
|
1812
|
+
}
|
|
1813
|
+
await createVersion({
|
|
1814
|
+
nestId,
|
|
1815
|
+
nodeId,
|
|
1816
|
+
version: publishedVersion,
|
|
1817
|
+
content: node.body || "",
|
|
1818
|
+
author: userEmail,
|
|
1819
|
+
status: "published",
|
|
1820
|
+
tags: currentTags,
|
|
1821
|
+
changeNote: patch.changeNote
|
|
1822
|
+
});
|
|
1823
|
+
await setApprovedVersion(
|
|
1824
|
+
nestId,
|
|
1825
|
+
nodeId,
|
|
1826
|
+
publishedVersion,
|
|
1827
|
+
userEmail
|
|
1828
|
+
);
|
|
1829
|
+
responseVersion = publishedVersion;
|
|
1830
|
+
}
|
|
1831
|
+
return { node, version: responseVersion };
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
throw toSafeError(
|
|
1834
|
+
err,
|
|
1835
|
+
`updateNode failed (${nestId}/${nodeId})`,
|
|
1836
|
+
"Couldn't save your changes. Please reload the document and try again."
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
function derivedIdOf(sourceNodeId) {
|
|
1842
|
+
return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
|
|
1843
|
+
}
|
|
1844
|
+
async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
1845
|
+
const { storage } = await engineCache.get(nestId);
|
|
1636
1846
|
let node;
|
|
1637
1847
|
try {
|
|
1638
1848
|
node = await storage.readDocument(nodeId);
|
|
1639
1849
|
} catch {
|
|
1640
1850
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1641
1851
|
}
|
|
1642
|
-
if (
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
1647
|
-
}
|
|
1648
|
-
if (patch.tags) {
|
|
1649
|
-
const newTags = patch.tags.map(normalizeTag2);
|
|
1650
|
-
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1651
|
-
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1852
|
+
if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
|
|
1853
|
+
throw new LockedError(
|
|
1854
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
|
|
1855
|
+
);
|
|
1652
1856
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1857
|
+
const leaf = nodeId.split("/").pop();
|
|
1858
|
+
const segments = folderToSegments(folder);
|
|
1859
|
+
const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
|
|
1860
|
+
if (newId === nodeId) return { node, previousId: nodeId };
|
|
1861
|
+
try {
|
|
1862
|
+
await storage.readDocument(newId);
|
|
1863
|
+
throw new ConflictError(
|
|
1864
|
+
`A document already exists at that location: ${newId}`
|
|
1865
|
+
);
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
if (err instanceof ConflictError) throw err;
|
|
1655
1868
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1869
|
+
const moved = {
|
|
1870
|
+
...node,
|
|
1871
|
+
id: newId,
|
|
1872
|
+
frontmatter: {
|
|
1873
|
+
...node.frontmatter,
|
|
1874
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
await storage.writeDocument(newId, serializeDocument(moved));
|
|
1878
|
+
try {
|
|
1879
|
+
const nestPath = resolveNestPath(nestId);
|
|
1880
|
+
const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
|
|
1881
|
+
const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
|
|
1882
|
+
if (oldChain !== newChain) {
|
|
1883
|
+
await mkdir(dirname(newChain), { recursive: true });
|
|
1884
|
+
await rename(oldChain, newChain);
|
|
1885
|
+
}
|
|
1886
|
+
} catch {
|
|
1658
1887
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1888
|
+
await storage.deleteDocument(nodeId);
|
|
1889
|
+
const oldDerived = derivedIdOf(nodeId);
|
|
1890
|
+
const newDerived = derivedIdOf(newId);
|
|
1891
|
+
const db = getDb();
|
|
1892
|
+
await db.transaction(async (tx) => {
|
|
1893
|
+
for (const [table, column] of [
|
|
1894
|
+
["node_versions", "node_id"],
|
|
1895
|
+
["review_requests", "node_id"],
|
|
1896
|
+
["approved_versions", "node_id"],
|
|
1897
|
+
["annotation_threads", "node_id"],
|
|
1898
|
+
["comments", "node_id"],
|
|
1899
|
+
["node_tag_index", "node_id"]
|
|
1900
|
+
]) {
|
|
1901
|
+
await tx.run(
|
|
1902
|
+
`UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
|
|
1903
|
+
[newId, nestId, nodeId]
|
|
1904
|
+
);
|
|
1905
|
+
await tx.run(
|
|
1906
|
+
`UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
|
|
1907
|
+
[newDerived, nestId, oldDerived]
|
|
1663
1908
|
);
|
|
1664
1909
|
}
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
}
|
|
1670
|
-
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1671
|
-
const currentTags = node.frontmatter.tags || [];
|
|
1672
|
-
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
1673
|
-
throw new LockedError(
|
|
1674
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
1910
|
+
await tx.run(
|
|
1911
|
+
`UPDATE stewards SET node_pattern = ?
|
|
1912
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
1913
|
+
[newId, nestId, nodeId]
|
|
1675
1914
|
);
|
|
1915
|
+
await tx.run(
|
|
1916
|
+
`UPDATE grants SET target = ?
|
|
1917
|
+
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
1918
|
+
[newId, nestId, nodeId]
|
|
1919
|
+
);
|
|
1920
|
+
await tx.run(
|
|
1921
|
+
"UPDATE edges SET from_node = ? WHERE nest_id = ? AND from_node = ?",
|
|
1922
|
+
[newId, nestId, nodeId]
|
|
1923
|
+
);
|
|
1924
|
+
await tx.run(
|
|
1925
|
+
"UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
|
|
1926
|
+
[newId, nestId, nodeId]
|
|
1927
|
+
);
|
|
1928
|
+
await tx.run(
|
|
1929
|
+
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
1930
|
+
[newId, nestId, nodeId]
|
|
1931
|
+
);
|
|
1932
|
+
await tx.run(
|
|
1933
|
+
`UPDATE run_steps SET node_id = ?
|
|
1934
|
+
WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
|
|
1935
|
+
[newId, nodeId, nestId]
|
|
1936
|
+
);
|
|
1937
|
+
});
|
|
1938
|
+
try {
|
|
1939
|
+
const derivedDoc = await storage.readDocument(oldDerived);
|
|
1940
|
+
await storage.writeDocument(
|
|
1941
|
+
newDerived,
|
|
1942
|
+
serializeDocument({ ...derivedDoc, id: newDerived })
|
|
1943
|
+
);
|
|
1944
|
+
await storage.deleteDocument(oldDerived);
|
|
1945
|
+
} catch {
|
|
1676
1946
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
1680
|
-
const newVersion = currentVersion + 1;
|
|
1681
|
-
node = {
|
|
1682
|
-
...node,
|
|
1683
|
-
frontmatter: {
|
|
1684
|
-
...node.frontmatter,
|
|
1685
|
-
version: newVersion,
|
|
1686
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1687
|
-
// Drop stale published-state checksum so the next verified read
|
|
1688
|
-
// doesn't flag this write as external drift.
|
|
1689
|
-
checksum: void 0
|
|
1690
|
-
}
|
|
1691
|
-
};
|
|
1692
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1693
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1694
|
-
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1695
|
-
try {
|
|
1696
|
-
await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
|
|
1697
|
-
} catch (err) {
|
|
1698
|
-
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1699
|
-
}
|
|
1700
|
-
await createVersion({
|
|
1701
|
-
nestId,
|
|
1702
|
-
nodeId,
|
|
1703
|
-
version: newVersion,
|
|
1704
|
-
content: node.body || "",
|
|
1705
|
-
author: userEmail,
|
|
1706
|
-
status: "draft",
|
|
1707
|
-
tags: currentTags,
|
|
1708
|
-
changeNote: patch.changeNote
|
|
1709
|
-
});
|
|
1710
|
-
responseVersion = newVersion;
|
|
1711
|
-
} else {
|
|
1712
|
-
node = {
|
|
1713
|
-
...node,
|
|
1714
|
-
frontmatter: {
|
|
1715
|
-
...node.frontmatter,
|
|
1716
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1717
|
-
checksum: void 0
|
|
1718
|
-
}
|
|
1719
|
-
};
|
|
1720
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1721
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1722
|
-
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1723
|
-
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1724
|
-
try {
|
|
1725
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
1726
|
-
editedBy: userEmail,
|
|
1727
|
-
note: patch.changeNote || "Auto-published on edit (no stewards)"
|
|
1728
|
-
});
|
|
1729
|
-
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
1730
|
-
node = result.node;
|
|
1731
|
-
} catch (err) {
|
|
1732
|
-
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
1733
|
-
}
|
|
1734
|
-
await createVersion({
|
|
1735
|
-
nestId,
|
|
1736
|
-
nodeId,
|
|
1737
|
-
version: publishedVersion,
|
|
1738
|
-
content: node.body || "",
|
|
1739
|
-
author: userEmail,
|
|
1740
|
-
status: "published",
|
|
1741
|
-
tags: currentTags,
|
|
1742
|
-
changeNote: patch.changeNote
|
|
1743
|
-
});
|
|
1744
|
-
await setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1745
|
-
responseVersion = publishedVersion;
|
|
1746
|
-
}
|
|
1747
|
-
return { node, version: responseVersion };
|
|
1947
|
+
await trackEvent("node.move", { nestId, nodeId, newId });
|
|
1948
|
+
return { node: moved, previousId: nodeId };
|
|
1748
1949
|
}
|
|
1749
1950
|
|
|
1750
1951
|
// src/nests/unsynced-service.ts
|
|
1751
1952
|
import { readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
1752
|
-
import { join as
|
|
1953
|
+
import { join as join3, relative, resolve } from "path";
|
|
1753
1954
|
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
1754
1955
|
function isNestStorageRoot(absPath) {
|
|
1755
1956
|
return resolve(absPath) === resolve(nestStorageRoot());
|
|
@@ -1765,7 +1966,7 @@ function scanMarkdown(dir) {
|
|
|
1765
1966
|
}
|
|
1766
1967
|
for (const e of entries) {
|
|
1767
1968
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1768
|
-
const full =
|
|
1969
|
+
const full = join3(dir, e.name);
|
|
1769
1970
|
if (e.isDirectory()) {
|
|
1770
1971
|
const sub = scanMarkdown(full);
|
|
1771
1972
|
count += sub.count;
|
|
@@ -1815,7 +2016,7 @@ function collectLeafFolders(rel, abs) {
|
|
|
1815
2016
|
if (!e.isDirectory()) continue;
|
|
1816
2017
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1817
2018
|
out.push(
|
|
1818
|
-
...collectLeafFolders(`${rel}/${e.name}`,
|
|
2019
|
+
...collectLeafFolders(`${rel}/${e.name}`, join3(abs, e.name))
|
|
1819
2020
|
);
|
|
1820
2021
|
}
|
|
1821
2022
|
return out;
|
|
@@ -1833,7 +2034,7 @@ function listUnsyncedFolders() {
|
|
|
1833
2034
|
if (!e.isDirectory()) continue;
|
|
1834
2035
|
if (e.name.startsWith(".")) continue;
|
|
1835
2036
|
if (RESERVED.has(e.name)) continue;
|
|
1836
|
-
const abs =
|
|
2037
|
+
const abs = join3(root, e.name);
|
|
1837
2038
|
if (isNestStorageRoot(abs)) continue;
|
|
1838
2039
|
out.push(...collectLeafFolders(e.name, abs));
|
|
1839
2040
|
}
|
|
@@ -1870,7 +2071,7 @@ function collectMarkdownFiles(dir, root) {
|
|
|
1870
2071
|
}
|
|
1871
2072
|
for (const e of entries) {
|
|
1872
2073
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1873
|
-
const full =
|
|
2074
|
+
const full = join3(dir, e.name);
|
|
1874
2075
|
if (e.isDirectory()) {
|
|
1875
2076
|
out.push(...collectMarkdownFiles(full, root));
|
|
1876
2077
|
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
@@ -1888,7 +2089,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
1888
2089
|
`[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
|
|
1889
2090
|
);
|
|
1890
2091
|
assertSafeFolderName(folderName);
|
|
1891
|
-
const src =
|
|
2092
|
+
const src = join3(config.DATA_ROOT, folderName);
|
|
1892
2093
|
if (isNestStorageRoot(src)) {
|
|
1893
2094
|
throw new ValidationError("Folder is not eligible for sync");
|
|
1894
2095
|
}
|
|
@@ -2003,11 +2204,33 @@ nestRoutes.get("/", async (c) => {
|
|
|
2003
2204
|
"SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
|
|
2004
2205
|
);
|
|
2005
2206
|
const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
|
|
2207
|
+
const stewardEmailRows = await db.all(
|
|
2208
|
+
`SELECT nest_id, user_email FROM stewards WHERE is_active = 1
|
|
2209
|
+
GROUP BY nest_id, user_email ORDER BY MIN(assigned_at)`
|
|
2210
|
+
);
|
|
2211
|
+
const stewardEmailsByNest = /* @__PURE__ */ new Map();
|
|
2212
|
+
for (const r of stewardEmailRows) {
|
|
2213
|
+
const arr = stewardEmailsByNest.get(r.nest_id) ?? [];
|
|
2214
|
+
if (arr.length < 8) arr.push(r.user_email);
|
|
2215
|
+
stewardEmailsByNest.set(r.nest_id, arr);
|
|
2216
|
+
}
|
|
2217
|
+
const collabEmailRows = await db.all(
|
|
2218
|
+
`SELECT nc.nest_id, u.email FROM nest_collaborators nc
|
|
2219
|
+
JOIN users u ON u.id = nc.user_id ORDER BY nc.granted_at`
|
|
2220
|
+
);
|
|
2221
|
+
const collabEmailsByNest = /* @__PURE__ */ new Map();
|
|
2222
|
+
for (const r of collabEmailRows) {
|
|
2223
|
+
const arr = collabEmailsByNest.get(r.nest_id) ?? [];
|
|
2224
|
+
if (arr.length < 8) arr.push(r.email);
|
|
2225
|
+
collabEmailsByNest.set(r.nest_id, arr);
|
|
2226
|
+
}
|
|
2006
2227
|
const withMeta = out.map((n) => ({
|
|
2007
2228
|
...n,
|
|
2008
2229
|
document_count: docByNest.get(n.id) ?? 0,
|
|
2009
2230
|
collaborator_count: collabByNest.get(n.id) ?? 0,
|
|
2010
|
-
steward_count: stewardByNest.get(n.id) ?? 0
|
|
2231
|
+
steward_count: stewardByNest.get(n.id) ?? 0,
|
|
2232
|
+
steward_emails: stewardEmailsByNest.get(n.id) ?? [],
|
|
2233
|
+
collaborator_emails: collabEmailsByNest.get(n.id) ?? []
|
|
2011
2234
|
}));
|
|
2012
2235
|
return c.json({ nests: withMeta });
|
|
2013
2236
|
});
|
|
@@ -2019,7 +2242,10 @@ nestRoutes.post("/", async (c) => {
|
|
|
2019
2242
|
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
2020
2243
|
return c.json({ nest }, 201);
|
|
2021
2244
|
});
|
|
2022
|
-
nestRoutes.post("
|
|
2245
|
+
nestRoutes.post("/:nestId", async (c) => {
|
|
2246
|
+
if (c.req.param("nestId") !== "import") {
|
|
2247
|
+
throw new NotFoundError("Not found");
|
|
2248
|
+
}
|
|
2023
2249
|
const body = await c.req.json();
|
|
2024
2250
|
if (!body.name) {
|
|
2025
2251
|
throw new ValidationError("name is required");
|
|
@@ -2033,14 +2259,17 @@ nestRoutes.post("/import", async (c) => {
|
|
|
2033
2259
|
);
|
|
2034
2260
|
return c.json({ nest, documents }, 201);
|
|
2035
2261
|
});
|
|
2036
|
-
|
|
2262
|
+
async function handleUnsyncedList(c) {
|
|
2037
2263
|
const userId = c.get("userId");
|
|
2038
2264
|
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
2039
2265
|
throw new ForbiddenError("Only the server admin can list unsynced folders");
|
|
2040
2266
|
}
|
|
2041
2267
|
return c.json({ folders: listUnsyncedFolders() });
|
|
2042
|
-
}
|
|
2043
|
-
nestRoutes.post("/
|
|
2268
|
+
}
|
|
2269
|
+
nestRoutes.post("/:nestId/sync", async (c) => {
|
|
2270
|
+
if (c.req.param("nestId") !== "unsynced") {
|
|
2271
|
+
throw new NotFoundError("Not found");
|
|
2272
|
+
}
|
|
2044
2273
|
const userId = c.get("userId");
|
|
2045
2274
|
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
2046
2275
|
throw new ForbiddenError("Only the server admin can sync folders");
|
|
@@ -2058,6 +2287,7 @@ nestRoutes.post("/unsynced/sync", async (c) => {
|
|
|
2058
2287
|
});
|
|
2059
2288
|
nestRoutes.get("/:nestId", async (c) => {
|
|
2060
2289
|
const nestId = c.req.param("nestId");
|
|
2290
|
+
if (nestId === "unsynced") return handleUnsyncedList(c);
|
|
2061
2291
|
const userId = c.get("userId");
|
|
2062
2292
|
const permission = await effectivePermission(nestId, userId);
|
|
2063
2293
|
if (permission === "none") {
|
|
@@ -2784,10 +3014,10 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2784
3014
|
})) : void 0
|
|
2785
3015
|
}, 201);
|
|
2786
3016
|
});
|
|
2787
|
-
nodeRoutes.get("/:nodeId{
|
|
3017
|
+
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
2788
3018
|
const nestId = c.req.param("nestId");
|
|
2789
3019
|
const nodeId = c.req.param("nodeId");
|
|
2790
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
3020
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-D5PDWXBR.js");
|
|
2791
3021
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
2792
3022
|
nestId,
|
|
2793
3023
|
nodeId
|
|
@@ -2805,10 +3035,10 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
2805
3035
|
ownerEmail
|
|
2806
3036
|
});
|
|
2807
3037
|
});
|
|
2808
|
-
nodeRoutes.get("/:nodeId{
|
|
3038
|
+
nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
2809
3039
|
const nestId = c.req.param("nestId");
|
|
2810
3040
|
const nodeId = c.req.param("nodeId");
|
|
2811
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
3041
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-A2YAKYQW.js");
|
|
2812
3042
|
const allVersions = await getVersions2(nestId, nodeId);
|
|
2813
3043
|
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
2814
3044
|
const db = getDb();
|
|
@@ -2837,10 +3067,10 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
2837
3067
|
currentVersion: allVersions[0]?.version || 0
|
|
2838
3068
|
});
|
|
2839
3069
|
});
|
|
2840
|
-
nodeRoutes.get("/:nodeId{
|
|
3070
|
+
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
2841
3071
|
const nestId = c.req.param("nestId");
|
|
2842
3072
|
const nodeId = c.req.param("nodeId");
|
|
2843
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
3073
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-VLSUT6KK.js");
|
|
2844
3074
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
2845
3075
|
return c.json({ reviews: history });
|
|
2846
3076
|
});
|
|
@@ -2879,6 +3109,50 @@ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
|
2879
3109
|
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2880
3110
|
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2881
3111
|
});
|
|
3112
|
+
nodeRoutes.post("/:nodeId{.+}/move", async (c) => {
|
|
3113
|
+
const nestId = c.req.param("nestId");
|
|
3114
|
+
const nodeId = c.req.param("nodeId");
|
|
3115
|
+
const userId = c.get("userId");
|
|
3116
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3117
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
3118
|
+
return c.json(
|
|
3119
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
3120
|
+
403
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3124
|
+
if (typeof body.folder !== "string") {
|
|
3125
|
+
throw new ValidationError('folder (a string; "" for the nest root) is required');
|
|
3126
|
+
}
|
|
3127
|
+
const { node, previousId } = await moveNode(nestId, nodeId, body.folder, userEmail);
|
|
3128
|
+
return c.json({ ok: true, node: toNodeResponse(node), previousId });
|
|
3129
|
+
});
|
|
3130
|
+
nodeRoutes.get("/:nodeId{.+}/watchers", async (c) => {
|
|
3131
|
+
const nestId = c.req.param("nestId");
|
|
3132
|
+
const nodeId = c.req.param("nodeId");
|
|
3133
|
+
return c.json({ watchers: await listWatchers(nestId, nodeId) });
|
|
3134
|
+
});
|
|
3135
|
+
nodeRoutes.post("/:nodeId{.+}/watchers", async (c) => {
|
|
3136
|
+
const nestId = c.req.param("nestId");
|
|
3137
|
+
const nodeId = c.req.param("nodeId");
|
|
3138
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3139
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
3140
|
+
if (!email || !email.includes("@")) {
|
|
3141
|
+
throw new ValidationError("email is required");
|
|
3142
|
+
}
|
|
3143
|
+
await addWatcher(nestId, nodeId, email, await getUserEmail(c));
|
|
3144
|
+
return c.json({ watchers: await listWatchers(nestId, nodeId) }, 201);
|
|
3145
|
+
});
|
|
3146
|
+
nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
|
|
3147
|
+
const nestId = c.req.param("nestId");
|
|
3148
|
+
const nodeId = c.req.param("nodeId");
|
|
3149
|
+
const email = c.req.query("email")?.trim() ?? "";
|
|
3150
|
+
if (!email) throw new ValidationError("email query param is required");
|
|
3151
|
+
if (!await removeWatcher(nestId, nodeId, email)) {
|
|
3152
|
+
throw new NotFoundError("Not watching");
|
|
3153
|
+
}
|
|
3154
|
+
return c.json({ watchers: await listWatchers(nestId, nodeId) });
|
|
3155
|
+
});
|
|
2882
3156
|
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
2883
3157
|
const nestId = c.req.param("nestId");
|
|
2884
3158
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3123,10 +3397,8 @@ annotationRoutes.post("/:nodeId{.+}/annotations", async (c) => {
|
|
|
3123
3397
|
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
3124
3398
|
return c.json({ thread }, 201);
|
|
3125
3399
|
});
|
|
3126
|
-
|
|
3400
|
+
async function handleAnnotationReply(c, nodeId, threadId) {
|
|
3127
3401
|
const nestId = c.req.param("nestId");
|
|
3128
|
-
const nodeId = getNodeId(c);
|
|
3129
|
-
const threadId = c.req.param("threadId");
|
|
3130
3402
|
const userId = c.get("userId");
|
|
3131
3403
|
const userEmail = await resolveCallerEmail(userId);
|
|
3132
3404
|
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
@@ -3139,7 +3411,11 @@ annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/comments", async (c) =
|
|
|
3139
3411
|
const thread = await addComment(nestId, nodeId, threadId, userEmail, input.body);
|
|
3140
3412
|
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
3141
3413
|
return c.json({ thread });
|
|
3142
|
-
}
|
|
3414
|
+
}
|
|
3415
|
+
annotationRoutes.post(
|
|
3416
|
+
"/:nodeId{.+}/annotations/:threadId/comments",
|
|
3417
|
+
async (c) => handleAnnotationReply(c, getNodeId(c), c.req.param("threadId"))
|
|
3418
|
+
);
|
|
3143
3419
|
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/resolve", async (c) => {
|
|
3144
3420
|
const nestId = c.req.param("nestId");
|
|
3145
3421
|
const nodeId = getNodeId(c);
|
|
@@ -3209,30 +3485,231 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
|
3209
3485
|
import { Hono as Hono7 } from "hono";
|
|
3210
3486
|
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
3211
3487
|
|
|
3212
|
-
// src/
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
"
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3488
|
+
// src/tables/service.ts
|
|
3489
|
+
function splitCsvLine(line) {
|
|
3490
|
+
const out = [];
|
|
3491
|
+
let cur = "";
|
|
3492
|
+
let inQuotes = false;
|
|
3493
|
+
for (let i = 0; i < line.length; i++) {
|
|
3494
|
+
const ch = line[i];
|
|
3495
|
+
if (inQuotes) {
|
|
3496
|
+
if (ch === '"') {
|
|
3497
|
+
if (line[i + 1] === '"') {
|
|
3498
|
+
cur += '"';
|
|
3499
|
+
i++;
|
|
3500
|
+
} else {
|
|
3501
|
+
inQuotes = false;
|
|
3502
|
+
}
|
|
3503
|
+
} else {
|
|
3504
|
+
cur += ch;
|
|
3505
|
+
}
|
|
3506
|
+
} else if (ch === '"') {
|
|
3507
|
+
inQuotes = true;
|
|
3508
|
+
} else if (ch === ",") {
|
|
3509
|
+
out.push(cur);
|
|
3510
|
+
cur = "";
|
|
3511
|
+
} else {
|
|
3512
|
+
cur += ch;
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
out.push(cur);
|
|
3516
|
+
return out.map((s) => s.trim());
|
|
3517
|
+
}
|
|
3518
|
+
var DIRECTIVES = /* @__PURE__ */ new Set(["key", "number", "date", "text"]);
|
|
3519
|
+
function parseTable(body) {
|
|
3520
|
+
const lines = body.split(/\r?\n/).filter((l) => l.trim() !== "" && !l.trim().startsWith("#"));
|
|
3521
|
+
if (!lines.length) {
|
|
3522
|
+
throw new ValidationError("table body is empty \u2014 expected a CSV header row");
|
|
3523
|
+
}
|
|
3524
|
+
const columns = splitCsvLine(lines[0]).map((raw) => {
|
|
3525
|
+
const parts = raw.split(":").map((p) => p.trim());
|
|
3526
|
+
const name = parts[0];
|
|
3527
|
+
if (!name) throw new ValidationError("empty column name in header");
|
|
3528
|
+
let type = "text";
|
|
3529
|
+
let key = false;
|
|
3530
|
+
for (const d of parts.slice(1)) {
|
|
3531
|
+
const dl = d.toLowerCase();
|
|
3532
|
+
if (!DIRECTIVES.has(dl)) {
|
|
3533
|
+
throw new ValidationError(
|
|
3534
|
+
`unknown column directive ":${d}" on "${name}" \u2014 known: ${[...DIRECTIVES].join(", ")}`
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
if (dl === "key") key = true;
|
|
3538
|
+
else type = dl;
|
|
3539
|
+
}
|
|
3540
|
+
return { name, type, key };
|
|
3541
|
+
});
|
|
3542
|
+
const rows = lines.slice(1).map((l) => {
|
|
3543
|
+
const cells = splitCsvLine(l);
|
|
3544
|
+
while (cells.length < columns.length) cells.push("");
|
|
3545
|
+
return cells.slice(0, columns.length);
|
|
3546
|
+
});
|
|
3547
|
+
return { columns, rows };
|
|
3548
|
+
}
|
|
3549
|
+
var OPS = [">=", "<=", "!=", "=", ">", "<", "contains"];
|
|
3550
|
+
function parseQuery(q) {
|
|
3551
|
+
let rest = q.trim();
|
|
3552
|
+
const out = { where: [], select: null, limit: null };
|
|
3553
|
+
if (!rest) return out;
|
|
3554
|
+
const limitMatch = rest.match(/\blimit\s+(\d+)\s*$/i);
|
|
3555
|
+
if (limitMatch) {
|
|
3556
|
+
out.limit = parseInt(limitMatch[1], 10);
|
|
3557
|
+
rest = rest.slice(0, limitMatch.index).trim();
|
|
3558
|
+
}
|
|
3559
|
+
const selectMatch = rest.match(/\bselect\s+(.+)$/i);
|
|
3560
|
+
if (selectMatch) {
|
|
3561
|
+
out.select = selectMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
3562
|
+
rest = rest.slice(0, selectMatch.index).trim();
|
|
3563
|
+
}
|
|
3564
|
+
if (!rest) return out;
|
|
3565
|
+
if (!/^where\s/i.test(rest)) {
|
|
3566
|
+
throw new ValidationError(
|
|
3567
|
+
'query must start with "where" (or be empty for all rows)'
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
rest = rest.replace(/^where\s+/i, "");
|
|
3571
|
+
for (const clause of rest.split(/\s+and\s+/i)) {
|
|
3572
|
+
const m = clause.match(
|
|
3573
|
+
/^\s*([\w .\-]+?)\s*(>=|<=|!=|=|>|<|contains)\s*(.+)\s*$/i
|
|
3574
|
+
);
|
|
3575
|
+
if (!m) {
|
|
3576
|
+
throw new ValidationError(
|
|
3577
|
+
`can't parse condition "${clause}" \u2014 expected <column> <op> <value> with op in ${OPS.join(" ")}`
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
let value = m[3].trim();
|
|
3581
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
3582
|
+
value = value.slice(1, -1);
|
|
3583
|
+
}
|
|
3584
|
+
out.where.push({ column: m[1].trim(), op: m[2].toLowerCase(), value });
|
|
3585
|
+
}
|
|
3586
|
+
return out;
|
|
3587
|
+
}
|
|
3588
|
+
function compare(cell, cond, type) {
|
|
3589
|
+
if (cond.op === "contains") {
|
|
3590
|
+
return cell.toLowerCase().includes(cond.value.toLowerCase());
|
|
3591
|
+
}
|
|
3592
|
+
let a = cell;
|
|
3593
|
+
let b = cond.value;
|
|
3594
|
+
if (type === "number") {
|
|
3595
|
+
a = Number(cell);
|
|
3596
|
+
b = Number(cond.value);
|
|
3597
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
3598
|
+
} else if (type === "date") {
|
|
3599
|
+
a = Date.parse(cell);
|
|
3600
|
+
b = Date.parse(cond.value);
|
|
3601
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
3602
|
+
} else {
|
|
3603
|
+
a = cell.toLowerCase();
|
|
3604
|
+
b = cond.value.toLowerCase();
|
|
3605
|
+
}
|
|
3606
|
+
switch (cond.op) {
|
|
3607
|
+
case "=":
|
|
3608
|
+
return a === b;
|
|
3609
|
+
case "!=":
|
|
3610
|
+
return a !== b;
|
|
3611
|
+
case ">":
|
|
3612
|
+
return a > b;
|
|
3613
|
+
case ">=":
|
|
3614
|
+
return a >= b;
|
|
3615
|
+
case "<":
|
|
3616
|
+
return a < b;
|
|
3617
|
+
case "<=":
|
|
3618
|
+
return a <= b;
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
function runQuery(table, query) {
|
|
3622
|
+
const colIndex = new Map(table.columns.map((c, i) => [c.name.toLowerCase(), i]));
|
|
3623
|
+
const resolve2 = (name) => {
|
|
3624
|
+
const i = colIndex.get(name.toLowerCase());
|
|
3625
|
+
if (i === void 0) {
|
|
3626
|
+
throw new ValidationError(
|
|
3627
|
+
`unknown column "${name}" \u2014 table has: ${table.columns.map((c) => c.name).join(", ")}`
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
return i;
|
|
3631
|
+
};
|
|
3632
|
+
const conds = query.where.map((c) => ({
|
|
3633
|
+
...c,
|
|
3634
|
+
idx: resolve2(c.column),
|
|
3635
|
+
type: table.columns[resolve2(c.column)].type
|
|
3636
|
+
}));
|
|
3637
|
+
let rows = table.rows.filter(
|
|
3638
|
+
(r) => conds.every((c) => compare(r[c.idx], c, c.type))
|
|
3639
|
+
);
|
|
3640
|
+
const matched = rows.length;
|
|
3641
|
+
if (query.limit !== null) rows = rows.slice(0, query.limit);
|
|
3642
|
+
let columns = table.columns.map((c) => c.name);
|
|
3643
|
+
if (query.select) {
|
|
3644
|
+
const idxs = query.select.map(resolve2);
|
|
3645
|
+
columns = query.select.map((s, i) => table.columns[idxs[i]].name);
|
|
3646
|
+
rows = rows.map((r) => idxs.map((i) => r[i]));
|
|
3647
|
+
}
|
|
3648
|
+
return { columns, rows, matched, total: table.rows.length };
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
// src/tables/query.ts
|
|
3652
|
+
async function queryTableNode(nestId, ref, q, includeDrafts = false) {
|
|
3653
|
+
const { storage } = await engineCache.get(nestId);
|
|
3654
|
+
const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
|
|
3655
|
+
let doc;
|
|
3656
|
+
const m = /^\[\[(.+)\]\]$/.exec(ref.trim());
|
|
3657
|
+
if (m) {
|
|
3658
|
+
const wanted = m[1].trim().toLowerCase();
|
|
3659
|
+
const all = await storage.discoverDocuments();
|
|
3660
|
+
doc = all.find(
|
|
3661
|
+
(d) => String(d.frontmatter.title || "").toLowerCase() === wanted && isVisible(d)
|
|
3662
|
+
);
|
|
3663
|
+
} else {
|
|
3664
|
+
try {
|
|
3665
|
+
doc = await storage.readDocument(ref.trim());
|
|
3666
|
+
} catch {
|
|
3667
|
+
}
|
|
3668
|
+
if (doc && !isVisible(doc)) doc = void 0;
|
|
3669
|
+
}
|
|
3670
|
+
if (!doc) throw new NotFoundError(`Table not found: ${ref}`);
|
|
3671
|
+
if (doc.frontmatter.type !== "table") {
|
|
3672
|
+
throw new ValidationError(
|
|
3673
|
+
`"${doc.id}" is type "${doc.frontmatter.type || "document"}" \u2014 table-query only reads table nodes`
|
|
3674
|
+
);
|
|
3675
|
+
}
|
|
3676
|
+
const parsed = parseTable(doc.body || "");
|
|
3677
|
+
const result = runQuery(parsed, parseQuery(q));
|
|
3678
|
+
return {
|
|
3679
|
+
table: doc.id,
|
|
3680
|
+
version: doc.frontmatter.version ?? null,
|
|
3681
|
+
columns: result.columns,
|
|
3682
|
+
rows: result.rows,
|
|
3683
|
+
matched: result.matched,
|
|
3684
|
+
returned: result.rows.length,
|
|
3685
|
+
total: result.total
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
// src/nodes/prompt-compiler.ts
|
|
3690
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
3691
|
+
"the",
|
|
3692
|
+
"a",
|
|
3693
|
+
"an",
|
|
3694
|
+
"and",
|
|
3695
|
+
"or",
|
|
3696
|
+
"but",
|
|
3697
|
+
"if",
|
|
3698
|
+
"then",
|
|
3699
|
+
"else",
|
|
3700
|
+
"so",
|
|
3701
|
+
"as",
|
|
3702
|
+
"is",
|
|
3703
|
+
"are",
|
|
3704
|
+
"was",
|
|
3705
|
+
"were",
|
|
3706
|
+
"be",
|
|
3707
|
+
"been",
|
|
3708
|
+
"being",
|
|
3709
|
+
"have",
|
|
3710
|
+
"has",
|
|
3711
|
+
"had",
|
|
3712
|
+
"do",
|
|
3236
3713
|
"does",
|
|
3237
3714
|
"did",
|
|
3238
3715
|
"done",
|
|
@@ -3872,6 +4349,19 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3872
4349
|
}
|
|
3873
4350
|
});
|
|
3874
4351
|
});
|
|
4352
|
+
queryRoutes.post("/table-query", async (c) => {
|
|
4353
|
+
const nestId = c.req.param("nestId");
|
|
4354
|
+
const body = await c.req.json();
|
|
4355
|
+
const ref = typeof body.table === "string" ? body.table.trim() : "";
|
|
4356
|
+
if (!ref) throw new ValidationError("table is required \u2014 [[Title]] or node id");
|
|
4357
|
+
const result = await queryTableNode(
|
|
4358
|
+
nestId,
|
|
4359
|
+
ref,
|
|
4360
|
+
typeof body.query === "string" ? body.query : "",
|
|
4361
|
+
body.include_drafts === true
|
|
4362
|
+
);
|
|
4363
|
+
return c.json(result);
|
|
4364
|
+
});
|
|
3875
4365
|
queryRoutes.post("/query", async (c) => {
|
|
3876
4366
|
const body = await c.req.json();
|
|
3877
4367
|
if (!body.query) {
|
|
@@ -4075,12 +4565,11 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
4075
4565
|
});
|
|
4076
4566
|
|
|
4077
4567
|
// src/mcp/routes.ts
|
|
4078
|
-
import { Hono as
|
|
4568
|
+
import { Hono as Hono9 } from "hono";
|
|
4079
4569
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4080
4570
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4081
4571
|
|
|
4082
4572
|
// src/telemetry/trace-log.ts
|
|
4083
|
-
var RETENTION_DAYS = 14;
|
|
4084
4573
|
var PRUNE_EVERY = 500;
|
|
4085
4574
|
var insertsSincePrune = 0;
|
|
4086
4575
|
async function logTraceEvent(e) {
|
|
@@ -4105,10 +4594,13 @@ async function logTraceEvent(e) {
|
|
|
4105
4594
|
);
|
|
4106
4595
|
if (++insertsSincePrune >= PRUNE_EVERY) {
|
|
4107
4596
|
insertsSincePrune = 0;
|
|
4108
|
-
const
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4597
|
+
const retentionDays = config.TRACE_RETENTION_DAYS;
|
|
4598
|
+
if (retentionDays > 0) {
|
|
4599
|
+
const cutoff = new Date(
|
|
4600
|
+
Date.now() - retentionDays * 864e5
|
|
4601
|
+
).toISOString();
|
|
4602
|
+
await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
|
|
4603
|
+
}
|
|
4112
4604
|
}
|
|
4113
4605
|
} catch {
|
|
4114
4606
|
}
|
|
@@ -4153,7 +4645,7 @@ async function listTraceEvents(filters = {}) {
|
|
|
4153
4645
|
}
|
|
4154
4646
|
|
|
4155
4647
|
// src/mcp/workflow-tools.ts
|
|
4156
|
-
import { v4 as
|
|
4648
|
+
import { v4 as uuid7 } from "uuid";
|
|
4157
4649
|
|
|
4158
4650
|
// src/shared/node-id.ts
|
|
4159
4651
|
function assertSafeNodeId(rawId) {
|
|
@@ -4181,18 +4673,6 @@ function assertSafeNodeId(rawId) {
|
|
|
4181
4673
|
|
|
4182
4674
|
// src/workflow/run-service.ts
|
|
4183
4675
|
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
4676
|
var RUNNABLE_TYPES = ["agent", "skill"];
|
|
4197
4677
|
async function flowSubgraph(nestId, agentNode) {
|
|
4198
4678
|
const db = getDb();
|
|
@@ -4483,168 +4963,8 @@ async function listRuns(nestId, opts) {
|
|
|
4483
4963
|
}
|
|
4484
4964
|
|
|
4485
4965
|
// 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
4966
|
import { Hono as Hono8 } from "hono";
|
|
4491
4967
|
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
4968
|
function toResponse(e, t) {
|
|
4649
4969
|
return {
|
|
4650
4970
|
id: e.id,
|
|
@@ -4746,7 +5066,7 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
|
|
|
4746
5066
|
}
|
|
4747
5067
|
}
|
|
4748
5068
|
}
|
|
4749
|
-
var edgeRoutes = new
|
|
5069
|
+
var edgeRoutes = new Hono8();
|
|
4750
5070
|
edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
4751
5071
|
const nestId = c.req.param("nestId");
|
|
4752
5072
|
const db = getDb();
|
|
@@ -4814,7 +5134,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
|
4814
5134
|
);
|
|
4815
5135
|
const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
|
|
4816
5136
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4817
|
-
const id =
|
|
5137
|
+
const id = uuid6();
|
|
4818
5138
|
await db.transaction(async (tx) => {
|
|
4819
5139
|
if (type.is_flow) {
|
|
4820
5140
|
await assertNoFlowCycle(tx, nestId, fromNode, toNode);
|
|
@@ -4829,6 +5149,54 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
|
4829
5149
|
const row = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
4830
5150
|
return c.json({ edge: toResponse(row, type) }, 201);
|
|
4831
5151
|
});
|
|
5152
|
+
edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
5153
|
+
const nestId = c.req.param("nestId");
|
|
5154
|
+
const id = c.req.param("id");
|
|
5155
|
+
const body = await c.req.json();
|
|
5156
|
+
const db = getDb();
|
|
5157
|
+
const row = await db.get(
|
|
5158
|
+
"SELECT * FROM edges WHERE id = ? AND nest_id = ?",
|
|
5159
|
+
[id, nestId]
|
|
5160
|
+
);
|
|
5161
|
+
if (!row) throw new NotFoundError("Edge not found");
|
|
5162
|
+
let type = await db.get(
|
|
5163
|
+
"SELECT * FROM edge_types WHERE id = ?",
|
|
5164
|
+
[row.type_id]
|
|
5165
|
+
);
|
|
5166
|
+
if (typeof body.type === "string" && body.type.trim()) {
|
|
5167
|
+
const next = await db.get(
|
|
5168
|
+
"SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
|
|
5169
|
+
[nestId, body.type.trim(), body.type.trim()]
|
|
5170
|
+
);
|
|
5171
|
+
if (!next) {
|
|
5172
|
+
throw new ValidationError(
|
|
5173
|
+
`unknown edge type "${body.type}" \u2014 define it in the registry first`
|
|
5174
|
+
);
|
|
5175
|
+
}
|
|
5176
|
+
if (next.is_flow && !type.is_flow) {
|
|
5177
|
+
await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
|
|
5178
|
+
}
|
|
5179
|
+
type = next;
|
|
5180
|
+
}
|
|
5181
|
+
let mode = row.condition_mode;
|
|
5182
|
+
let stored = row.condition;
|
|
5183
|
+
if (body.condition_mode !== void 0 || body.condition !== void 0) {
|
|
5184
|
+
const v = await validateCondition(
|
|
5185
|
+
nestId,
|
|
5186
|
+
body.condition_mode ?? row.condition_mode,
|
|
5187
|
+
body.condition !== void 0 ? body.condition : row.condition
|
|
5188
|
+
);
|
|
5189
|
+
mode = v.mode;
|
|
5190
|
+
stored = v.stored;
|
|
5191
|
+
}
|
|
5192
|
+
await db.run(
|
|
5193
|
+
`UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
|
|
5194
|
+
WHERE id = ?`,
|
|
5195
|
+
[type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
|
|
5196
|
+
);
|
|
5197
|
+
const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
5198
|
+
return c.json({ edge: toResponse(updated, type) });
|
|
5199
|
+
});
|
|
4832
5200
|
edgeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
|
|
4833
5201
|
const nestId = c.req.param("nestId");
|
|
4834
5202
|
const id = c.req.param("id");
|
|
@@ -5158,7 +5526,7 @@ async function createEdge(ctx, args) {
|
|
|
5158
5526
|
);
|
|
5159
5527
|
const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
|
|
5160
5528
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5161
|
-
const id =
|
|
5529
|
+
const id = uuid7();
|
|
5162
5530
|
await db.transaction(async (tx) => {
|
|
5163
5531
|
if (type.is_flow) {
|
|
5164
5532
|
await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
|
|
@@ -5221,7 +5589,7 @@ async function upsertEdgeType(ctx, args) {
|
|
|
5221
5589
|
`INSERT INTO edge_types
|
|
5222
5590
|
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
5223
5591
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
5224
|
-
[
|
|
5592
|
+
[uuid7(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
|
|
5225
5593
|
);
|
|
5226
5594
|
}
|
|
5227
5595
|
});
|
|
@@ -5297,6 +5665,24 @@ var TOOL_DEFINITIONS = [
|
|
|
5297
5665
|
required: ["query"]
|
|
5298
5666
|
}
|
|
5299
5667
|
},
|
|
5668
|
+
{
|
|
5669
|
+
name: "context_table_query",
|
|
5670
|
+
description: "Deterministic lookup against a TABLE node (CSV fact store). Fetch exact rows instead of reading the whole table: where <col> <op> <value> [and \u2026] [select cols] [limit N]. Ops: = != > >= < <= contains. Same query + same version = same rows, always.",
|
|
5671
|
+
inputSchema: {
|
|
5672
|
+
type: "object",
|
|
5673
|
+
properties: {
|
|
5674
|
+
table: {
|
|
5675
|
+
type: "string",
|
|
5676
|
+
description: 'The table node \u2014 "[[Title]]" or a node id like nodes/price-list'
|
|
5677
|
+
},
|
|
5678
|
+
query: {
|
|
5679
|
+
type: "string",
|
|
5680
|
+
description: 'e.g. where region = "EU" and price > 100 select sku, price limit 5. Empty = all rows.'
|
|
5681
|
+
}
|
|
5682
|
+
},
|
|
5683
|
+
required: ["table"]
|
|
5684
|
+
}
|
|
5685
|
+
},
|
|
5300
5686
|
{
|
|
5301
5687
|
name: "context_get",
|
|
5302
5688
|
description: "Get the FULL content of a specific node by title or ID.",
|
|
@@ -5566,6 +5952,9 @@ var TOOL_DEFINITIONS = [
|
|
|
5566
5952
|
}
|
|
5567
5953
|
];
|
|
5568
5954
|
async function resolveLlmBody(ctx, node) {
|
|
5955
|
+
if (!await canReadNode(ctx.nestId, node.id, ctx.userId, ctx.userEmail)) {
|
|
5956
|
+
return null;
|
|
5957
|
+
}
|
|
5569
5958
|
if (!await isStewardshipEnabled(ctx.nestId)) return node.body || "";
|
|
5570
5959
|
const approved = await getApprovedVersion(ctx.nestId, node.id);
|
|
5571
5960
|
if (approved == null) return null;
|
|
@@ -5659,6 +6048,20 @@ ${nodeList}`;
|
|
|
5659
6048
|
return `Found ${matches.length} node(s) matching "${args.query}":
|
|
5660
6049
|
|
|
5661
6050
|
${results}`;
|
|
6051
|
+
}
|
|
6052
|
+
case "context_table_query": {
|
|
6053
|
+
const result = await queryTableNode(
|
|
6054
|
+
nestId,
|
|
6055
|
+
String(args.table ?? ""),
|
|
6056
|
+
String(args.query ?? ""),
|
|
6057
|
+
false
|
|
6058
|
+
);
|
|
6059
|
+
const lines = [
|
|
6060
|
+
result.columns.join(","),
|
|
6061
|
+
...result.rows.map((r) => r.join(","))
|
|
6062
|
+
];
|
|
6063
|
+
return `${result.returned} of ${result.matched} matching rows (table ${result.table} v${result.version}, ${result.total} total):
|
|
6064
|
+
${lines.join("\n")}`;
|
|
5662
6065
|
}
|
|
5663
6066
|
case "context_query": {
|
|
5664
6067
|
const result = await queryEngine.query(args.query, {
|
|
@@ -6063,19 +6466,19 @@ ${list}`;
|
|
|
6063
6466
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
6064
6467
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
6065
6468
|
try {
|
|
6066
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
6067
|
-
const db = (await import("./client-
|
|
6469
|
+
const { createGrant: createGrant2 } = await import("./grants-service-6H4WUA2F.js");
|
|
6470
|
+
const db = (await import("./client-VZLX4THW.js")).getDb();
|
|
6068
6471
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
6069
6472
|
const e = normalizeEmail2(String(args.email || ""));
|
|
6070
6473
|
if (!e) return "email is required.";
|
|
6071
6474
|
let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
|
|
6072
6475
|
if (!row) {
|
|
6073
6476
|
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
6074
|
-
const { v4:
|
|
6075
|
-
const id =
|
|
6477
|
+
const { v4: uuid15 } = await import("uuid");
|
|
6478
|
+
const id = uuid15();
|
|
6076
6479
|
await db.run(
|
|
6077
6480
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
6078
|
-
[id, e, null, await hashPassword2(
|
|
6481
|
+
[id, e, null, await hashPassword2(uuid15())]
|
|
6079
6482
|
);
|
|
6080
6483
|
row = { id };
|
|
6081
6484
|
}
|
|
@@ -6130,7 +6533,7 @@ ${list}`;
|
|
|
6130
6533
|
|
|
6131
6534
|
// src/mcp/routes.ts
|
|
6132
6535
|
import { z } from "zod";
|
|
6133
|
-
var mcpRoutes = new
|
|
6536
|
+
var mcpRoutes = new Hono9();
|
|
6134
6537
|
async function getUserEmail2(userId) {
|
|
6135
6538
|
const db = getDb();
|
|
6136
6539
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
@@ -6196,87 +6599,851 @@ mcpRoutes.all("/", async (c) => {
|
|
|
6196
6599
|
});
|
|
6197
6600
|
|
|
6198
6601
|
// src/workflow/run-routes.ts
|
|
6199
|
-
import { Hono as
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
const
|
|
6220
|
-
const
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6602
|
+
import { Hono as Hono10 } from "hono";
|
|
6603
|
+
import { v4 as uuid8 } from "uuid";
|
|
6604
|
+
var RUNNABLE_TYPES2 = ["agent", "skill"];
|
|
6605
|
+
var safeJson2 = (s) => {
|
|
6606
|
+
if (!s) return null;
|
|
6607
|
+
try {
|
|
6608
|
+
return JSON.parse(s);
|
|
6609
|
+
} catch {
|
|
6610
|
+
return null;
|
|
6611
|
+
}
|
|
6612
|
+
};
|
|
6613
|
+
async function flowSubgraph2(nestId, agentNode) {
|
|
6614
|
+
const db = getDb();
|
|
6615
|
+
const rows = await db.all(
|
|
6616
|
+
`SELECT e.id, e.from_node, e.to_node, e.condition_mode, e.condition, e.metadata,
|
|
6617
|
+
t.name as type_name, t.color as type_color
|
|
6618
|
+
FROM edges e JOIN edge_types t ON t.id = e.type_id
|
|
6619
|
+
WHERE e.nest_id = ? AND t.is_flow = 1`,
|
|
6620
|
+
[nestId]
|
|
6621
|
+
);
|
|
6622
|
+
const byFrom = /* @__PURE__ */ new Map();
|
|
6623
|
+
for (const r of rows) {
|
|
6624
|
+
if (!byFrom.has(r.from_node)) byFrom.set(r.from_node, []);
|
|
6625
|
+
byFrom.get(r.from_node).push(r);
|
|
6626
|
+
}
|
|
6627
|
+
const seen = /* @__PURE__ */ new Set([agentNode]);
|
|
6628
|
+
const queue = [agentNode];
|
|
6629
|
+
const edges = [];
|
|
6630
|
+
while (queue.length) {
|
|
6631
|
+
const cur = queue.shift();
|
|
6632
|
+
for (const e of byFrom.get(cur) ?? []) {
|
|
6633
|
+
edges.push(e);
|
|
6634
|
+
if (!seen.has(e.to_node)) {
|
|
6635
|
+
seen.add(e.to_node);
|
|
6636
|
+
queue.push(e.to_node);
|
|
6637
|
+
}
|
|
6638
|
+
}
|
|
6639
|
+
}
|
|
6640
|
+
return edges.map((e) => ({
|
|
6641
|
+
id: e.id,
|
|
6642
|
+
from_node: e.from_node,
|
|
6643
|
+
to_node: e.to_node,
|
|
6644
|
+
type: { name: e.type_name, color: e.type_color },
|
|
6645
|
+
condition_mode: e.condition_mode,
|
|
6646
|
+
condition: e.condition_mode === "structured" ? safeJson2(e.condition) : e.condition,
|
|
6647
|
+
metadata: safeJson2(e.metadata)
|
|
6648
|
+
}));
|
|
6649
|
+
}
|
|
6650
|
+
async function referencedDefinitions2(nestId, edges) {
|
|
6651
|
+
const terms = /* @__PURE__ */ new Set();
|
|
6652
|
+
for (const e of edges) {
|
|
6653
|
+
if (e.condition_mode === "structured" && e.condition && typeof e.condition === "object") {
|
|
6654
|
+
const t = e.condition.term;
|
|
6655
|
+
if (typeof t === "string" && t.trim()) terms.add(t.trim());
|
|
6656
|
+
}
|
|
6657
|
+
}
|
|
6658
|
+
if (!terms.size) return [];
|
|
6659
|
+
const out = [];
|
|
6660
|
+
try {
|
|
6661
|
+
const db = getDb();
|
|
6662
|
+
for (const term of terms) {
|
|
6663
|
+
const row = await db.get(
|
|
6664
|
+
"SELECT term, definition, linked_tag FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
|
|
6665
|
+
[nestId, term]
|
|
6666
|
+
);
|
|
6667
|
+
if (row) out.push(row);
|
|
6668
|
+
}
|
|
6669
|
+
} catch {
|
|
6670
|
+
}
|
|
6671
|
+
return out;
|
|
6672
|
+
}
|
|
6673
|
+
async function buildRunBundle(nestId, agentNode) {
|
|
6674
|
+
const { storage } = await engineCache.get(nestId);
|
|
6675
|
+
let doc;
|
|
6676
|
+
try {
|
|
6677
|
+
doc = await storage.readDocument(agentNode);
|
|
6678
|
+
} catch {
|
|
6679
|
+
throw new NotFoundError(`Agent node not found: ${agentNode}`);
|
|
6680
|
+
}
|
|
6681
|
+
const nodeType = doc.frontmatter.type || "document";
|
|
6682
|
+
if (!RUNNABLE_TYPES2.includes(nodeType)) {
|
|
6683
|
+
throw new ValidationError(
|
|
6684
|
+
`"${agentNode}" is type "${nodeType}" \u2014 only agent/skill nodes are runnable`
|
|
6685
|
+
);
|
|
6686
|
+
}
|
|
6687
|
+
const edges = await flowSubgraph2(nestId, agentNode);
|
|
6688
|
+
const definitions = await referencedDefinitions2(nestId, edges);
|
|
6689
|
+
const reachable = /* @__PURE__ */ new Set([agentNode]);
|
|
6690
|
+
for (const e of edges) {
|
|
6691
|
+
reachable.add(e.from_node);
|
|
6692
|
+
reachable.add(e.to_node);
|
|
6693
|
+
}
|
|
6694
|
+
const node_types = {};
|
|
6695
|
+
const tools = {};
|
|
6696
|
+
for (const nid of reachable) {
|
|
6697
|
+
try {
|
|
6698
|
+
const d = await storage.readDocument(nid);
|
|
6699
|
+
const t = d.frontmatter.type || "document";
|
|
6700
|
+
node_types[nid] = t;
|
|
6701
|
+
if (t === "tool") tools[nid] = d.body || "";
|
|
6702
|
+
} catch {
|
|
6703
|
+
}
|
|
6704
|
+
}
|
|
6705
|
+
const env = await envValues(nestId);
|
|
6706
|
+
return {
|
|
6707
|
+
agent: {
|
|
6708
|
+
id: agentNode,
|
|
6709
|
+
title: String(doc.frontmatter.title ?? agentNode),
|
|
6710
|
+
type: nodeType,
|
|
6711
|
+
schedule: doc.frontmatter.metadata?.schedule ?? null,
|
|
6712
|
+
content: doc.body || ""
|
|
6713
|
+
},
|
|
6714
|
+
edges,
|
|
6715
|
+
node_types,
|
|
6716
|
+
tools,
|
|
6717
|
+
env,
|
|
6718
|
+
definitions
|
|
6719
|
+
};
|
|
6720
|
+
}
|
|
6721
|
+
var runTriggerRoutes = new Hono10();
|
|
6722
|
+
async function resolveParentRun2(nestId, parentRunId, childAgent) {
|
|
6723
|
+
if (!config.FEATURE_SUBAGENT_RUNS) {
|
|
6724
|
+
throw new ValidationError(
|
|
6725
|
+
"Sub-agent runs are not enabled on this server (FEATURE_SUBAGENT_RUNS)."
|
|
6726
|
+
);
|
|
6727
|
+
}
|
|
6728
|
+
const db = getDb();
|
|
6729
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6730
|
+
let cursor = parentRunId;
|
|
6731
|
+
let parentDepth = -1;
|
|
6732
|
+
for (let i = 0; i <= config.SUBAGENT_MAX_DEPTH + 1; i++) {
|
|
6733
|
+
if (!cursor) break;
|
|
6734
|
+
const row = await db.get(
|
|
6735
|
+
"SELECT id, nest_id, agent_node, status, depth, parent_run_id FROM runs WHERE id = ?",
|
|
6736
|
+
[cursor]
|
|
6737
|
+
);
|
|
6738
|
+
if (!row) throw new NotFoundError(`Parent run not found: ${cursor}`);
|
|
6739
|
+
if (row.nest_id !== nestId) {
|
|
6740
|
+
throw new NotFoundError(`Parent run not found: ${cursor}`);
|
|
6741
|
+
}
|
|
6742
|
+
if (cursor === parentRunId && row.status !== "running") {
|
|
6743
|
+
throw new ConflictError(
|
|
6744
|
+
`Parent run is ${row.status} \u2014 cannot spawn a sub-agent under a closed run`
|
|
6745
|
+
);
|
|
6746
|
+
}
|
|
6747
|
+
if (row.agent_node === childAgent || seen.has(childAgent)) {
|
|
6748
|
+
throw new ConflictError(
|
|
6749
|
+
`re-entrancy: "${childAgent}" is already running in this call chain \u2014 sub-agent recursion must be acyclic`
|
|
6750
|
+
);
|
|
6751
|
+
}
|
|
6752
|
+
seen.add(row.agent_node);
|
|
6753
|
+
if (cursor === parentRunId) parentDepth = row.depth;
|
|
6754
|
+
cursor = row.parent_run_id;
|
|
6755
|
+
}
|
|
6756
|
+
const depth = parentDepth + 1;
|
|
6757
|
+
if (depth > config.SUBAGENT_MAX_DEPTH) {
|
|
6758
|
+
throw new ForbiddenError(
|
|
6759
|
+
`sub-agent depth ${depth} exceeds the limit of ${config.SUBAGENT_MAX_DEPTH}`
|
|
6760
|
+
);
|
|
6761
|
+
}
|
|
6762
|
+
const kids = await db.get(
|
|
6763
|
+
"SELECT COUNT(*) as c FROM runs WHERE parent_run_id = ?",
|
|
6764
|
+
[parentRunId]
|
|
6765
|
+
);
|
|
6766
|
+
if (kids.c >= config.SUBAGENT_MAX_CHILDREN) {
|
|
6767
|
+
throw new ForbiddenError(
|
|
6768
|
+
`parent run already has ${kids.c} children (limit ${config.SUBAGENT_MAX_CHILDREN})`
|
|
6769
|
+
);
|
|
6770
|
+
}
|
|
6771
|
+
return { depth };
|
|
6772
|
+
}
|
|
6773
|
+
runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
|
|
6774
|
+
const nestId = c.req.param("nestId");
|
|
6775
|
+
const agentNode = assertSafeNodeId(c.req.param("agentNode"));
|
|
6776
|
+
const body = await c.req.json().catch(() => ({}));
|
|
6777
|
+
let parentRunId = null;
|
|
6778
|
+
let depth = 0;
|
|
6779
|
+
if (body.parent_run_id !== void 0 && body.parent_run_id !== null) {
|
|
6780
|
+
if (typeof body.parent_run_id !== "string") {
|
|
6781
|
+
throw new ValidationError("parent_run_id must be a string");
|
|
6782
|
+
}
|
|
6783
|
+
({ depth } = await resolveParentRun2(nestId, body.parent_run_id, agentNode));
|
|
6784
|
+
parentRunId = body.parent_run_id;
|
|
6785
|
+
}
|
|
6786
|
+
if (parentRunId === null) {
|
|
6787
|
+
const { n } = await getDb().get(
|
|
6788
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
6789
|
+
WHERE nest_id = ? AND parent_run_id IS NULL AND status = 'running'`,
|
|
6790
|
+
[nestId]
|
|
6791
|
+
);
|
|
6792
|
+
if (n >= config.RUN_MAX_CONCURRENT_ROOTS) {
|
|
6793
|
+
throw new ConflictError(
|
|
6794
|
+
`too many concurrent runs: ${n} already running (cap ${config.RUN_MAX_CONCURRENT_ROOTS}). Let some finish, or raise RUN_MAX_CONCURRENT_ROOTS.`
|
|
6795
|
+
);
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
6798
|
+
const bundle = await buildRunBundle(nestId, agentNode);
|
|
6799
|
+
const triggeredBy = await resolveCallerEmail(c.get("userId"));
|
|
6800
|
+
const id = `run_${uuid8()}`;
|
|
6801
|
+
await getDb().run(
|
|
6802
|
+
`INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
|
|
6803
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
|
|
6804
|
+
[
|
|
6805
|
+
id,
|
|
6806
|
+
nestId,
|
|
6807
|
+
agentNode,
|
|
6808
|
+
triggeredBy,
|
|
6809
|
+
body.inputs === void 0 ? null : JSON.stringify(body.inputs),
|
|
6810
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
6811
|
+
parentRunId,
|
|
6812
|
+
depth
|
|
6813
|
+
]
|
|
6814
|
+
);
|
|
6815
|
+
return c.json(
|
|
6816
|
+
{
|
|
6817
|
+
run_id: id,
|
|
6818
|
+
parent_run_id: parentRunId,
|
|
6819
|
+
depth,
|
|
6820
|
+
...bundle,
|
|
6821
|
+
trace_hint: {
|
|
6822
|
+
retrieval: "POST /context is deterministic \u2014 record selector+hops per read so the run replays to the identical read-set",
|
|
6823
|
+
steps: "POST /runs/{run_id}/steps per action; PATCH /runs/{run_id} to close"
|
|
6824
|
+
}
|
|
6825
|
+
},
|
|
6826
|
+
201
|
|
6827
|
+
);
|
|
6828
|
+
});
|
|
6829
|
+
var runListRoutes = new Hono10();
|
|
6830
|
+
runListRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
6831
|
+
const nestId = c.req.param("nestId");
|
|
6832
|
+
const db = getDb();
|
|
6833
|
+
let sql = "SELECT * FROM runs WHERE nest_id = ?";
|
|
6834
|
+
const args = [nestId];
|
|
6835
|
+
const agent = c.req.query("agent");
|
|
6836
|
+
const status = c.req.query("status");
|
|
6837
|
+
if (agent) {
|
|
6838
|
+
sql += " AND agent_node = ?";
|
|
6839
|
+
args.push(agent);
|
|
6840
|
+
}
|
|
6841
|
+
if (status) {
|
|
6842
|
+
sql += " AND status = ?";
|
|
6843
|
+
args.push(status);
|
|
6844
|
+
}
|
|
6845
|
+
if (c.req.query("unclaimed") === "true") {
|
|
6846
|
+
sql += " AND status = 'running' AND claimed_by IS NULL";
|
|
6847
|
+
}
|
|
6848
|
+
sql += " ORDER BY started_at DESC LIMIT ?";
|
|
6849
|
+
args.push(Math.min(Math.max(Number(c.req.query("limit")) || 50, 1), 500));
|
|
6850
|
+
const rows = await db.all(sql, args);
|
|
6851
|
+
const canSeeDetail = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
6852
|
+
return c.json({
|
|
6853
|
+
count: rows.length,
|
|
6854
|
+
runs: rows.map((r) => ({
|
|
6855
|
+
...r,
|
|
6856
|
+
inputs: canSeeDetail ? safeJson2(r.inputs) : null,
|
|
6857
|
+
trace: canSeeDetail ? safeJson2(r.trace) : null
|
|
6858
|
+
}))
|
|
6859
|
+
});
|
|
6860
|
+
});
|
|
6861
|
+
var runDetailRoutes = new Hono10();
|
|
6862
|
+
async function loadRunWithPermission(c, runId, required) {
|
|
6863
|
+
const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
|
|
6864
|
+
runId
|
|
6865
|
+
]);
|
|
6866
|
+
if (!run) throw new NotFoundError("Run not found");
|
|
6233
6867
|
const nestScope = c.get("nestScope");
|
|
6234
6868
|
if (nestScope && nestScope !== run.nest_id) {
|
|
6235
6869
|
throw new ForbiddenError("API key not authorized for this nest");
|
|
6236
6870
|
}
|
|
6237
6871
|
if (required === "write") {
|
|
6238
6872
|
if (isSuspended()) {
|
|
6239
|
-
throw new
|
|
6873
|
+
throw new ForbiddenError(
|
|
6874
|
+
`Server suspended by PromptOwl: ${getSuspensionReason()}`
|
|
6875
|
+
);
|
|
6240
6876
|
}
|
|
6241
6877
|
if (!getCurrentLicense()?.valid) {
|
|
6242
|
-
throw new
|
|
6878
|
+
throw new ForbiddenError(
|
|
6879
|
+
"A valid PromptOwl license is required to write runs."
|
|
6880
|
+
);
|
|
6243
6881
|
}
|
|
6244
6882
|
}
|
|
6245
6883
|
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
6884
|
if (permissionLevel(perm) < permissionLevel(required)) {
|
|
6250
|
-
throw new
|
|
6251
|
-
`This run belongs to a nest where you lack ${required} access.`
|
|
6252
|
-
);
|
|
6885
|
+
throw new NotFoundError("Run not found");
|
|
6253
6886
|
}
|
|
6254
6887
|
c.set("runPermission", perm);
|
|
6255
6888
|
return run;
|
|
6256
6889
|
}
|
|
6257
6890
|
runDetailRoutes.post("/:id/steps", requireWorkflowPlane, async (c) => {
|
|
6258
6891
|
const run = await loadRunWithPermission(c, c.req.param("id"), "write");
|
|
6892
|
+
if (run.status !== "running") {
|
|
6893
|
+
throw new ConflictError(`Run is ${run.status} \u2014 steps can't be appended`);
|
|
6894
|
+
}
|
|
6259
6895
|
const body = await c.req.json();
|
|
6260
|
-
|
|
6896
|
+
if (typeof body.action !== "string" || !body.action.trim()) {
|
|
6897
|
+
throw new ValidationError("action is required (read|write|branch|notify|external)");
|
|
6898
|
+
}
|
|
6899
|
+
const action = body.action.trim();
|
|
6900
|
+
const nodeId = typeof body.node_id === "string" ? body.node_id : null;
|
|
6901
|
+
const edgeId = typeof body.edge_id === "string" ? body.edge_id : null;
|
|
6902
|
+
const detail = body.detail === void 0 ? null : JSON.stringify(body.detail);
|
|
6903
|
+
const stepCount = await getDb().get(
|
|
6904
|
+
"SELECT COUNT(*) as c FROM run_steps WHERE run_id = ?",
|
|
6905
|
+
[run.id]
|
|
6906
|
+
);
|
|
6907
|
+
if (stepCount.c >= config.RUN_MAX_STEPS) {
|
|
6908
|
+
throw new ConflictError(
|
|
6909
|
+
`run has reached the step limit (${config.RUN_MAX_STEPS})`
|
|
6910
|
+
);
|
|
6911
|
+
}
|
|
6912
|
+
const db = getDb();
|
|
6913
|
+
let seq = 0;
|
|
6914
|
+
await db.transaction(async (tx) => {
|
|
6915
|
+
const last = await tx.get(
|
|
6916
|
+
"SELECT MAX(seq) as m FROM run_steps WHERE run_id = ?",
|
|
6917
|
+
[run.id]
|
|
6918
|
+
);
|
|
6919
|
+
seq = (last.m ?? 0) + 1;
|
|
6920
|
+
await tx.run(
|
|
6921
|
+
`INSERT INTO run_steps (run_id, seq, node_id, edge_id, action, detail, at)
|
|
6922
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
6923
|
+
[run.id, seq, nodeId, edgeId, action, detail, (/* @__PURE__ */ new Date()).toISOString()]
|
|
6924
|
+
);
|
|
6925
|
+
});
|
|
6261
6926
|
return c.json({ ok: true, seq }, 201);
|
|
6262
6927
|
});
|
|
6263
6928
|
runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
6264
6929
|
const run = await loadRunWithPermission(c, c.req.param("id"), "write");
|
|
6930
|
+
if (run.status !== "running") {
|
|
6931
|
+
throw new ConflictError(`Run already terminal (${run.status})`);
|
|
6932
|
+
}
|
|
6265
6933
|
const body = await c.req.json();
|
|
6266
|
-
const
|
|
6934
|
+
const status = body.status;
|
|
6935
|
+
if (typeof status !== "string" || !["running", "succeeded", "failed", "cancelled"].includes(status)) {
|
|
6936
|
+
throw new ValidationError("status must be running | succeeded | failed | cancelled");
|
|
6937
|
+
}
|
|
6938
|
+
const terminal = status !== "running";
|
|
6939
|
+
await getDb().run(
|
|
6940
|
+
`UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ? WHERE id = ?`,
|
|
6941
|
+
[
|
|
6942
|
+
status,
|
|
6943
|
+
body.trace === void 0 ? null : JSON.stringify(body.trace),
|
|
6944
|
+
terminal ? (/* @__PURE__ */ new Date()).toISOString() : null,
|
|
6945
|
+
run.id
|
|
6946
|
+
]
|
|
6947
|
+
);
|
|
6948
|
+
if (status === "failed") {
|
|
6949
|
+
const err = body.trace && typeof body.trace === "object" ? String(body.trace.error ?? "").slice(0, 200) : "";
|
|
6950
|
+
void dispatchEvent({
|
|
6951
|
+
kind: "run_failed",
|
|
6952
|
+
nestId: run.nest_id,
|
|
6953
|
+
subjectId: run.id,
|
|
6954
|
+
actor: run.triggered_by,
|
|
6955
|
+
message: `Run of *${run.agent_node}* failed${err ? ` \u2014 ${err}` : ""}`
|
|
6956
|
+
});
|
|
6957
|
+
}
|
|
6267
6958
|
return c.json({ ok: true, status });
|
|
6268
6959
|
});
|
|
6960
|
+
runDetailRoutes.get("/:id/bundle", requireWorkflowPlane, async (c) => {
|
|
6961
|
+
const run = await loadRunWithPermission(c, c.req.param("id"), "write");
|
|
6962
|
+
if (run.status !== "running") {
|
|
6963
|
+
throw new ConflictError(`Run is ${run.status} \u2014 nothing to execute`);
|
|
6964
|
+
}
|
|
6965
|
+
const claimer = await resolveCallerEmail(c.get("userId"));
|
|
6966
|
+
const res = await getDb().run(
|
|
6967
|
+
`UPDATE runs SET claimed_by = ?, claimed_at = ?
|
|
6968
|
+
WHERE id = ? AND claimed_by IS NULL`,
|
|
6969
|
+
[claimer, (/* @__PURE__ */ new Date()).toISOString(), run.id]
|
|
6970
|
+
);
|
|
6971
|
+
if (!res.changes) {
|
|
6972
|
+
throw new ConflictError("Run already claimed by another runner");
|
|
6973
|
+
}
|
|
6974
|
+
const bundle = await buildRunBundle(run.nest_id, run.agent_node);
|
|
6975
|
+
return c.json({
|
|
6976
|
+
run_id: run.id,
|
|
6977
|
+
parent_run_id: run.parent_run_id,
|
|
6978
|
+
depth: run.depth,
|
|
6979
|
+
inputs: safeJson2(run.inputs),
|
|
6980
|
+
...bundle
|
|
6981
|
+
});
|
|
6982
|
+
});
|
|
6269
6983
|
runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
|
|
6270
6984
|
const run = await loadRunWithPermission(c, c.req.param("id"), "read");
|
|
6271
6985
|
const canSeeDetail = permissionLevel(c.get("runPermission")) >= permissionLevel("write");
|
|
6272
|
-
const
|
|
6273
|
-
|
|
6986
|
+
const steps = await getDb().all(
|
|
6987
|
+
"SELECT seq, node_id, edge_id, action, detail, at FROM run_steps WHERE run_id = ? ORDER BY seq",
|
|
6988
|
+
[run.id]
|
|
6989
|
+
);
|
|
6990
|
+
return c.json({
|
|
6991
|
+
run: {
|
|
6992
|
+
...run,
|
|
6993
|
+
inputs: canSeeDetail ? safeJson2(run.inputs) : null,
|
|
6994
|
+
trace: canSeeDetail ? safeJson2(run.trace) : null
|
|
6995
|
+
},
|
|
6996
|
+
steps: steps.map((s) => ({
|
|
6997
|
+
...s,
|
|
6998
|
+
detail: canSeeDetail ? safeJson2(s.detail) : null
|
|
6999
|
+
}))
|
|
7000
|
+
});
|
|
6274
7001
|
});
|
|
6275
7002
|
|
|
6276
|
-
// src/
|
|
6277
|
-
import { Hono as
|
|
7003
|
+
// src/workflow/schedule-routes.ts
|
|
7004
|
+
import { Hono as Hono11 } from "hono";
|
|
6278
7005
|
import { v4 as uuid9 } from "uuid";
|
|
6279
|
-
var
|
|
7006
|
+
var RUNNABLE_TYPES3 = ["agent", "skill"];
|
|
7007
|
+
var MIN_EVERY = 5;
|
|
7008
|
+
var MAX_EVERY = 7 * 24 * 60;
|
|
7009
|
+
function toResponse2(s) {
|
|
7010
|
+
const last = s.last_run_at ? Date.parse(s.last_run_at) : NaN;
|
|
7011
|
+
return {
|
|
7012
|
+
id: s.id,
|
|
7013
|
+
agent_node: s.agent_node,
|
|
7014
|
+
every_minutes: s.every_minutes,
|
|
7015
|
+
enabled: !!s.enabled,
|
|
7016
|
+
last_run_at: s.last_run_at,
|
|
7017
|
+
// Derived, not stored: when the loop will next fire this schedule.
|
|
7018
|
+
next_due_at: s.enabled ? Number.isNaN(last) ? (/* @__PURE__ */ new Date()).toISOString() : new Date(last + s.every_minutes * 6e4).toISOString() : null,
|
|
7019
|
+
max_runs: s.max_runs,
|
|
7020
|
+
runs_created: s.runs_created,
|
|
7021
|
+
created_by: s.created_by,
|
|
7022
|
+
created_at: s.created_at
|
|
7023
|
+
};
|
|
7024
|
+
}
|
|
7025
|
+
function parseMaxRuns(v) {
|
|
7026
|
+
if (v === void 0 || v === null || v === "") return null;
|
|
7027
|
+
const n = Number(v);
|
|
7028
|
+
if (!Number.isInteger(n) || n < 1 || n > 1e4) {
|
|
7029
|
+
throw new ValidationError("max_runs must be a positive integer (or null for no bound)");
|
|
7030
|
+
}
|
|
7031
|
+
return n;
|
|
7032
|
+
}
|
|
7033
|
+
function parseEvery(v) {
|
|
7034
|
+
const n = Number(v);
|
|
7035
|
+
if (!Number.isInteger(n) || n < MIN_EVERY || n > MAX_EVERY) {
|
|
7036
|
+
throw new ValidationError(
|
|
7037
|
+
`every_minutes must be an integer between ${MIN_EVERY} and ${MAX_EVERY}`
|
|
7038
|
+
);
|
|
7039
|
+
}
|
|
7040
|
+
return n;
|
|
7041
|
+
}
|
|
7042
|
+
var scheduleRoutes = new Hono11();
|
|
7043
|
+
scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
7044
|
+
const nestId = c.req.param("nestId");
|
|
7045
|
+
const rows = await getDb().all(
|
|
7046
|
+
"SELECT * FROM schedules WHERE nest_id = ? ORDER BY created_at",
|
|
7047
|
+
[nestId]
|
|
7048
|
+
);
|
|
7049
|
+
return c.json({ count: rows.length, schedules: rows.map(toResponse2) });
|
|
7050
|
+
});
|
|
7051
|
+
scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
7052
|
+
const nestId = c.req.param("nestId");
|
|
7053
|
+
const body = await c.req.json();
|
|
7054
|
+
const agentRaw = typeof body.agent_node === "string" ? body.agent_node.trim() : "";
|
|
7055
|
+
if (!agentRaw) throw new ValidationError("agent_node is required");
|
|
7056
|
+
const agentNode = assertSafeNodeId(agentRaw);
|
|
7057
|
+
const every = parseEvery(body.every_minutes);
|
|
7058
|
+
const maxRuns = parseMaxRuns(body.max_runs);
|
|
7059
|
+
const { storage } = await engineCache.get(nestId);
|
|
7060
|
+
let doc;
|
|
7061
|
+
try {
|
|
7062
|
+
doc = await storage.readDocument(agentNode);
|
|
7063
|
+
} catch {
|
|
7064
|
+
throw new NotFoundError(`Agent node not found: ${agentNode}`);
|
|
7065
|
+
}
|
|
7066
|
+
const nodeType = doc.frontmatter.type || "document";
|
|
7067
|
+
if (!RUNNABLE_TYPES3.includes(nodeType)) {
|
|
7068
|
+
throw new ValidationError(
|
|
7069
|
+
`"${agentNode}" is type "${nodeType}" \u2014 only agent/skill nodes are schedulable`
|
|
7070
|
+
);
|
|
7071
|
+
}
|
|
7072
|
+
const db = getDb();
|
|
7073
|
+
const existing = await db.get(
|
|
7074
|
+
"SELECT id FROM schedules WHERE nest_id = ? AND agent_node = ?",
|
|
7075
|
+
[nestId, agentNode]
|
|
7076
|
+
);
|
|
7077
|
+
if (existing) {
|
|
7078
|
+
throw new ConflictError(
|
|
7079
|
+
"This agent already has a schedule \u2014 edit or delete it instead"
|
|
7080
|
+
);
|
|
7081
|
+
}
|
|
7082
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7083
|
+
const id = `sch_${uuid9()}`;
|
|
7084
|
+
await db.run(
|
|
7085
|
+
`INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
|
|
7086
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
|
|
7087
|
+
[
|
|
7088
|
+
id,
|
|
7089
|
+
nestId,
|
|
7090
|
+
agentNode,
|
|
7091
|
+
every,
|
|
7092
|
+
body.enabled === false ? 0 : 1,
|
|
7093
|
+
maxRuns,
|
|
7094
|
+
await resolveCallerEmail(c.get("userId")),
|
|
7095
|
+
now,
|
|
7096
|
+
now
|
|
7097
|
+
]
|
|
7098
|
+
);
|
|
7099
|
+
const row = await db.get("SELECT * FROM schedules WHERE id = ?", [id]);
|
|
7100
|
+
return c.json({ schedule: toResponse2(row) }, 201);
|
|
7101
|
+
});
|
|
7102
|
+
scheduleRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
7103
|
+
const nestId = c.req.param("nestId");
|
|
7104
|
+
const id = c.req.param("id");
|
|
7105
|
+
const body = await c.req.json();
|
|
7106
|
+
const db = getDb();
|
|
7107
|
+
const row = await db.get(
|
|
7108
|
+
"SELECT * FROM schedules WHERE id = ? AND nest_id = ?",
|
|
7109
|
+
[id, nestId]
|
|
7110
|
+
);
|
|
7111
|
+
if (!row) throw new NotFoundError("Schedule not found");
|
|
7112
|
+
const every = body.every_minutes === void 0 ? row.every_minutes : parseEvery(body.every_minutes);
|
|
7113
|
+
const enabled = body.enabled === void 0 ? row.enabled : body.enabled ? 1 : 0;
|
|
7114
|
+
const maxRuns = body.max_runs === void 0 ? row.max_runs : parseMaxRuns(body.max_runs);
|
|
7115
|
+
const runsCreated = body.max_runs !== void 0 || body.enabled && !row.enabled ? 0 : row.runs_created;
|
|
7116
|
+
await db.run(
|
|
7117
|
+
`UPDATE schedules SET every_minutes = ?, enabled = ?, max_runs = ?, runs_created = ?, updated_at = ?
|
|
7118
|
+
WHERE id = ?`,
|
|
7119
|
+
[every, enabled, maxRuns, runsCreated, (/* @__PURE__ */ new Date()).toISOString(), id]
|
|
7120
|
+
);
|
|
7121
|
+
const updated = await db.get("SELECT * FROM schedules WHERE id = ?", [id]);
|
|
7122
|
+
return c.json({ schedule: toResponse2(updated) });
|
|
7123
|
+
});
|
|
7124
|
+
scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
|
|
7125
|
+
const nestId = c.req.param("nestId");
|
|
7126
|
+
const id = c.req.param("id");
|
|
7127
|
+
const db = getDb();
|
|
7128
|
+
const row = await db.get(
|
|
7129
|
+
"SELECT id FROM schedules WHERE id = ? AND nest_id = ?",
|
|
7130
|
+
[id, nestId]
|
|
7131
|
+
);
|
|
7132
|
+
if (!row) throw new NotFoundError("Schedule not found");
|
|
7133
|
+
await db.run("DELETE FROM schedules WHERE id = ?", [id]);
|
|
7134
|
+
return c.json({ deleted: true });
|
|
7135
|
+
});
|
|
7136
|
+
|
|
7137
|
+
// src/notify/connector-routes.ts
|
|
7138
|
+
import { Hono as Hono12 } from "hono";
|
|
7139
|
+
import { v4 as uuid10 } from "uuid";
|
|
7140
|
+
var KNOWN_EVENTS = [
|
|
7141
|
+
"review_requested",
|
|
7142
|
+
"review_approved",
|
|
7143
|
+
"review_rejected",
|
|
7144
|
+
"run_failed"
|
|
7145
|
+
];
|
|
7146
|
+
var CHANNELS = ["slack", "teams", "webhook"];
|
|
7147
|
+
function toResponse3(r) {
|
|
7148
|
+
let events = [];
|
|
7149
|
+
try {
|
|
7150
|
+
events = JSON.parse(r.events);
|
|
7151
|
+
} catch {
|
|
7152
|
+
}
|
|
7153
|
+
const url = r.url.startsWith("env:") ? r.url : r.url.replace(/^(https:\/\/[^/]+\/).+(.{4})$/, "$1\u2022\u2022\u2022\u2022$2");
|
|
7154
|
+
return {
|
|
7155
|
+
id: r.id,
|
|
7156
|
+
channel: r.channel,
|
|
7157
|
+
url,
|
|
7158
|
+
events,
|
|
7159
|
+
enabled: !!r.enabled,
|
|
7160
|
+
created_by: r.created_by,
|
|
7161
|
+
created_at: r.created_at
|
|
7162
|
+
};
|
|
7163
|
+
}
|
|
7164
|
+
function parseEvents(v) {
|
|
7165
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
7166
|
+
throw new ValidationError(
|
|
7167
|
+
`events must be a non-empty array \u2014 any of ${KNOWN_EVENTS.join(", ")}, or ["*"]`
|
|
7168
|
+
);
|
|
7169
|
+
}
|
|
7170
|
+
for (const e of v) {
|
|
7171
|
+
if (e !== "*" && !KNOWN_EVENTS.includes(e)) {
|
|
7172
|
+
throw new ValidationError(`unknown event "${e}"`);
|
|
7173
|
+
}
|
|
7174
|
+
}
|
|
7175
|
+
return JSON.stringify(v);
|
|
7176
|
+
}
|
|
7177
|
+
function parseUrl(v) {
|
|
7178
|
+
const raw = typeof v === "string" ? v.trim() : "";
|
|
7179
|
+
if (raw.startsWith("env:") && raw.length > 4) return raw;
|
|
7180
|
+
if (isSafeConnectorUrl(raw)) return raw;
|
|
7181
|
+
throw new ValidationError(
|
|
7182
|
+
'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
|
|
7183
|
+
);
|
|
7184
|
+
}
|
|
7185
|
+
var connectorRoutes = new Hono12();
|
|
7186
|
+
connectorRoutes.get("/", async (c) => {
|
|
7187
|
+
const nestId = c.req.param("nestId");
|
|
7188
|
+
const rows = await getDb().all(
|
|
7189
|
+
"SELECT * FROM connectors WHERE nest_id = ? ORDER BY created_at",
|
|
7190
|
+
[nestId]
|
|
7191
|
+
);
|
|
7192
|
+
return c.json({ count: rows.length, connectors: rows.map(toResponse3) });
|
|
7193
|
+
});
|
|
7194
|
+
connectorRoutes.post("/", async (c) => {
|
|
7195
|
+
const nestId = c.req.param("nestId");
|
|
7196
|
+
const body = await c.req.json();
|
|
7197
|
+
const channel = String(body.channel ?? "");
|
|
7198
|
+
if (!CHANNELS.includes(channel)) {
|
|
7199
|
+
throw new ValidationError(`channel must be ${CHANNELS.join(" | ")}`);
|
|
7200
|
+
}
|
|
7201
|
+
const url = parseUrl(body.url);
|
|
7202
|
+
const events = parseEvents(body.events);
|
|
7203
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7204
|
+
const id = `con_${uuid10()}`;
|
|
7205
|
+
await getDb().run(
|
|
7206
|
+
`INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
|
|
7207
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
|
|
7208
|
+
[id, nestId, channel, url, events, await resolveCallerEmail(c.get("userId")), now, now]
|
|
7209
|
+
);
|
|
7210
|
+
const row = await getDb().get("SELECT * FROM connectors WHERE id = ?", [id]);
|
|
7211
|
+
return c.json({ connector: toResponse3(row) }, 201);
|
|
7212
|
+
});
|
|
7213
|
+
connectorRoutes.patch("/:id", async (c) => {
|
|
7214
|
+
const nestId = c.req.param("nestId");
|
|
7215
|
+
const id = c.req.param("id");
|
|
7216
|
+
const db = getDb();
|
|
7217
|
+
const row = await db.get(
|
|
7218
|
+
"SELECT * FROM connectors WHERE id = ? AND nest_id = ?",
|
|
7219
|
+
[id, nestId]
|
|
7220
|
+
);
|
|
7221
|
+
if (!row) throw new NotFoundError("Connector not found");
|
|
7222
|
+
const body = await c.req.json();
|
|
7223
|
+
const enabled = body.enabled === void 0 ? row.enabled : body.enabled ? 1 : 0;
|
|
7224
|
+
const events = body.events === void 0 ? row.events : parseEvents(body.events);
|
|
7225
|
+
const url = body.url === void 0 ? row.url : parseUrl(body.url);
|
|
7226
|
+
await db.run(
|
|
7227
|
+
"UPDATE connectors SET enabled = ?, events = ?, url = ?, updated_at = ? WHERE id = ?",
|
|
7228
|
+
[enabled, events, url, (/* @__PURE__ */ new Date()).toISOString(), id]
|
|
7229
|
+
);
|
|
7230
|
+
const updated = await db.get("SELECT * FROM connectors WHERE id = ?", [id]);
|
|
7231
|
+
return c.json({ connector: toResponse3(updated) });
|
|
7232
|
+
});
|
|
7233
|
+
connectorRoutes.delete("/:id", async (c) => {
|
|
7234
|
+
const nestId = c.req.param("nestId");
|
|
7235
|
+
const id = c.req.param("id");
|
|
7236
|
+
const db = getDb();
|
|
7237
|
+
const row = await db.get(
|
|
7238
|
+
"SELECT id FROM connectors WHERE id = ? AND nest_id = ?",
|
|
7239
|
+
[id, nestId]
|
|
7240
|
+
);
|
|
7241
|
+
if (!row) throw new NotFoundError("Connector not found");
|
|
7242
|
+
await db.run("DELETE FROM connectors WHERE id = ?", [id]);
|
|
7243
|
+
return c.json({ deleted: true });
|
|
7244
|
+
});
|
|
7245
|
+
|
|
7246
|
+
// src/workflow/hook-routes.ts
|
|
7247
|
+
import { Hono as Hono13 } from "hono";
|
|
7248
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
|
|
7249
|
+
var PRESETS = ["slack", "teams", "webhook"];
|
|
7250
|
+
var RUNNABLE_TYPES4 = ["agent", "skill"];
|
|
7251
|
+
function hookUrl(c, id) {
|
|
7252
|
+
const base = config.PUBLIC_BASE_URL || new URL(c.req.url).origin;
|
|
7253
|
+
return `${base}/hooks/${id}`;
|
|
7254
|
+
}
|
|
7255
|
+
function toResponse4(c, r, opts = {}) {
|
|
7256
|
+
return {
|
|
7257
|
+
// The id doubles as the capability token, so the LIST view masks it —
|
|
7258
|
+
// the full URL is shown exactly once, at creation (API-key semantics).
|
|
7259
|
+
id: opts.reveal ? r.id : `${r.id.slice(0, 7)}\u2026${r.id.slice(-4)}`,
|
|
7260
|
+
url: opts.reveal ? hookUrl(c, r.id) : null,
|
|
7261
|
+
agent_node: r.agent_node,
|
|
7262
|
+
preset: r.preset,
|
|
7263
|
+
enabled: !!r.enabled,
|
|
7264
|
+
fire_count: r.fire_count,
|
|
7265
|
+
last_fired_at: r.last_fired_at,
|
|
7266
|
+
created_by: r.created_by,
|
|
7267
|
+
created_at: r.created_at
|
|
7268
|
+
};
|
|
7269
|
+
}
|
|
7270
|
+
var hookNestRoutes = new Hono13();
|
|
7271
|
+
hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
7272
|
+
const nestId = c.req.param("nestId");
|
|
7273
|
+
const agent = c.req.query("agent");
|
|
7274
|
+
const rows = await getDb().all(
|
|
7275
|
+
`SELECT * FROM trigger_hooks WHERE nest_id = ?${agent ? " AND agent_node = ?" : ""} ORDER BY created_at`,
|
|
7276
|
+
agent ? [nestId, agent] : [nestId]
|
|
7277
|
+
);
|
|
7278
|
+
return c.json({ count: rows.length, hooks: rows.map((r) => toResponse4(c, r)) });
|
|
7279
|
+
});
|
|
7280
|
+
hookNestRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
7281
|
+
const nestId = c.req.param("nestId");
|
|
7282
|
+
const body = await c.req.json();
|
|
7283
|
+
const agentRaw = typeof body.agent_node === "string" ? body.agent_node.trim() : "";
|
|
7284
|
+
if (!agentRaw) throw new ValidationError("agent_node is required");
|
|
7285
|
+
const agentNode = assertSafeNodeId(agentRaw);
|
|
7286
|
+
const preset = String(body.preset ?? "webhook");
|
|
7287
|
+
if (!PRESETS.includes(preset)) {
|
|
7288
|
+
throw new ValidationError(`preset must be ${PRESETS.join(" | ")}`);
|
|
7289
|
+
}
|
|
7290
|
+
const { storage } = await engineCache.get(nestId);
|
|
7291
|
+
let doc;
|
|
7292
|
+
try {
|
|
7293
|
+
doc = await storage.readDocument(agentNode);
|
|
7294
|
+
} catch {
|
|
7295
|
+
throw new NotFoundError(`Agent node not found: ${agentNode}`);
|
|
7296
|
+
}
|
|
7297
|
+
const t = doc.frontmatter.type || "document";
|
|
7298
|
+
if (!RUNNABLE_TYPES4.includes(t)) {
|
|
7299
|
+
throw new ValidationError(`"${agentNode}" is type "${t}" \u2014 only agent/skill nodes are triggerable`);
|
|
7300
|
+
}
|
|
7301
|
+
const id = `hk_${randomUUID().replace(/-/g, "")}`;
|
|
7302
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7303
|
+
await getDb().run(
|
|
7304
|
+
`INSERT INTO trigger_hooks (id, nest_id, agent_node, preset, enabled, fire_count, last_fired_at, created_by, created_at)
|
|
7305
|
+
VALUES (?, ?, ?, ?, 1, 0, NULL, ?, ?)`,
|
|
7306
|
+
[id, nestId, agentNode, preset, await resolveCallerEmail(c.get("userId")), now]
|
|
7307
|
+
);
|
|
7308
|
+
const row = await getDb().get("SELECT * FROM trigger_hooks WHERE id = ?", [id]);
|
|
7309
|
+
return c.json({ hook: toResponse4(c, row, { reveal: true }) }, 201);
|
|
7310
|
+
});
|
|
7311
|
+
hookNestRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
|
|
7312
|
+
const nestId = c.req.param("nestId");
|
|
7313
|
+
const id = c.req.param("id");
|
|
7314
|
+
const db = getDb();
|
|
7315
|
+
const masked = id.match(/^(.{7})…(.{4})$/);
|
|
7316
|
+
const row = await db.get(
|
|
7317
|
+
masked ? "SELECT id FROM trigger_hooks WHERE nest_id = ? AND id LIKE ? AND id LIKE ?" : "SELECT id FROM trigger_hooks WHERE nest_id = ? AND id = ?",
|
|
7318
|
+
masked ? [nestId, `${masked[1]}%`, `%${masked[2]}`] : [nestId, id]
|
|
7319
|
+
);
|
|
7320
|
+
if (!row) throw new NotFoundError("Hook not found");
|
|
7321
|
+
await db.run("DELETE FROM trigger_hooks WHERE id = ?", [row.id]);
|
|
7322
|
+
return c.json({ deleted: true });
|
|
7323
|
+
});
|
|
7324
|
+
function safeEqual(a, b) {
|
|
7325
|
+
const ab = Buffer.from(a);
|
|
7326
|
+
const bb = Buffer.from(b);
|
|
7327
|
+
return ab.length === bb.length && timingSafeEqual2(ab, bb);
|
|
7328
|
+
}
|
|
7329
|
+
function slackSignatureValid(secret, ts, sig, rawBody) {
|
|
7330
|
+
if (!ts || !sig) return false;
|
|
7331
|
+
const age = Math.abs(Date.now() / 1e3 - Number(ts));
|
|
7332
|
+
if (!Number.isFinite(age) || age > 300) return false;
|
|
7333
|
+
const mine = `v0=${createHmac2("sha256", secret).update(`v0:${ts}:${rawBody}`).digest("hex")}`;
|
|
7334
|
+
return safeEqual(mine, sig);
|
|
7335
|
+
}
|
|
7336
|
+
function teamsSignatureValid(secret, authHeader, rawBody) {
|
|
7337
|
+
if (!authHeader?.startsWith("HMAC ")) return false;
|
|
7338
|
+
const mine = createHmac2("sha256", Buffer.from(secret, "base64")).update(rawBody, "utf8").digest("base64");
|
|
7339
|
+
return safeEqual(mine, authHeader.slice(5).trim());
|
|
7340
|
+
}
|
|
7341
|
+
function stripMentions(text) {
|
|
7342
|
+
return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
|
|
7343
|
+
}
|
|
7344
|
+
var hookFireRoutes = new Hono13();
|
|
7345
|
+
hookFireRoutes.post("/:token", async (c) => {
|
|
7346
|
+
const token = c.req.param("token");
|
|
7347
|
+
if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
|
|
7348
|
+
return c.json({ error: "rate limited" }, 429);
|
|
7349
|
+
}
|
|
7350
|
+
const db = getDb();
|
|
7351
|
+
const hook = await db.get(
|
|
7352
|
+
"SELECT * FROM trigger_hooks WHERE id = ? AND enabled = 1",
|
|
7353
|
+
[token]
|
|
7354
|
+
);
|
|
7355
|
+
if (!hook) throw new NotFoundError("Not found");
|
|
7356
|
+
if (!tryConsume(`hook:${hook.id}`, { max: 30, windowMs: 6e4 })) {
|
|
7357
|
+
return c.json({ error: "rate limited" }, 429);
|
|
7358
|
+
}
|
|
7359
|
+
const raw = await c.req.text();
|
|
7360
|
+
const ct = c.req.header("content-type") || "";
|
|
7361
|
+
let payload = {};
|
|
7362
|
+
if (ct.includes("form")) {
|
|
7363
|
+
payload = Object.fromEntries(new URLSearchParams(raw));
|
|
7364
|
+
} else if (raw) {
|
|
7365
|
+
try {
|
|
7366
|
+
payload = JSON.parse(raw);
|
|
7367
|
+
} catch {
|
|
7368
|
+
}
|
|
7369
|
+
}
|
|
7370
|
+
const env = await envValues(hook.nest_id);
|
|
7371
|
+
let inputs = null;
|
|
7372
|
+
let ack;
|
|
7373
|
+
if (hook.preset === "slack") {
|
|
7374
|
+
if (payload.type === "url_verification") {
|
|
7375
|
+
return c.json({ challenge: payload.challenge });
|
|
7376
|
+
}
|
|
7377
|
+
const secret = env.SLACK_SIGNING_SECRET;
|
|
7378
|
+
if (secret) {
|
|
7379
|
+
const ok = slackSignatureValid(
|
|
7380
|
+
secret,
|
|
7381
|
+
c.req.header("x-slack-request-timestamp"),
|
|
7382
|
+
c.req.header("x-slack-signature"),
|
|
7383
|
+
raw
|
|
7384
|
+
);
|
|
7385
|
+
if (!ok) return c.json({ error: "bad signature" }, 401);
|
|
7386
|
+
}
|
|
7387
|
+
if (payload.type === "event_callback") {
|
|
7388
|
+
const ev = payload.event ?? {};
|
|
7389
|
+
if (ev.type !== "app_mention") return c.json({ ok: true });
|
|
7390
|
+
inputs = { input: stripMentions(String(ev.text ?? "")), from: ev.user, channel: ev.channel };
|
|
7391
|
+
ack = () => c.json({ ok: true });
|
|
7392
|
+
} else {
|
|
7393
|
+
inputs = {
|
|
7394
|
+
input: String(payload.text ?? "").trim(),
|
|
7395
|
+
from: payload.user_name,
|
|
7396
|
+
channel: payload.channel_name
|
|
7397
|
+
};
|
|
7398
|
+
ack = (runId2) => c.json({
|
|
7399
|
+
response_type: "ephemeral",
|
|
7400
|
+
text: `\u23F3 Queued *${hook.agent_node}* (run ${runId2.slice(0, 12)}\u2026) \u2014 output lands for review in the nest.`
|
|
7401
|
+
});
|
|
7402
|
+
}
|
|
7403
|
+
} else if (hook.preset === "teams") {
|
|
7404
|
+
const secret = env.TEAMS_WEBHOOK_SECRET;
|
|
7405
|
+
if (secret && !teamsSignatureValid(secret, c.req.header("authorization"), raw)) {
|
|
7406
|
+
return c.json({ error: "bad signature" }, 401);
|
|
7407
|
+
}
|
|
7408
|
+
inputs = { input: stripMentions(String(payload.text ?? "")), from: payload.from?.name };
|
|
7409
|
+
ack = (runId2) => c.json({ type: "message", text: `\u23F3 Queued ${hook.agent_node} (run ${runId2.slice(0, 12)}\u2026)` });
|
|
7410
|
+
} else {
|
|
7411
|
+
inputs = Object.keys(payload).length ? payload : null;
|
|
7412
|
+
ack = (runId2) => c.json({ ok: true, run_id: runId2 }, 201);
|
|
7413
|
+
}
|
|
7414
|
+
const { n } = await db.get(
|
|
7415
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
7416
|
+
WHERE nest_id = ? AND parent_run_id IS NULL AND status = 'running'`,
|
|
7417
|
+
[hook.nest_id]
|
|
7418
|
+
);
|
|
7419
|
+
if (n >= config.RUN_MAX_CONCURRENT_ROOTS) {
|
|
7420
|
+
throw new ConflictError("too many concurrent runs \u2014 try again shortly");
|
|
7421
|
+
}
|
|
7422
|
+
const runId = `run_${randomUUID()}`;
|
|
7423
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7424
|
+
await db.run(
|
|
7425
|
+
`INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
|
|
7426
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?, NULL, 0)`,
|
|
7427
|
+
[
|
|
7428
|
+
runId,
|
|
7429
|
+
hook.nest_id,
|
|
7430
|
+
hook.agent_node,
|
|
7431
|
+
`hook:${hook.preset}:${hook.created_by}`,
|
|
7432
|
+
inputs === null ? null : JSON.stringify(inputs),
|
|
7433
|
+
now
|
|
7434
|
+
]
|
|
7435
|
+
);
|
|
7436
|
+
await db.run(
|
|
7437
|
+
"UPDATE trigger_hooks SET fire_count = fire_count + 1, last_fired_at = ? WHERE id = ?",
|
|
7438
|
+
[now, hook.id]
|
|
7439
|
+
);
|
|
7440
|
+
return ack(runId);
|
|
7441
|
+
});
|
|
7442
|
+
|
|
7443
|
+
// src/definitions/routes.ts
|
|
7444
|
+
import { Hono as Hono14 } from "hono";
|
|
7445
|
+
import { v4 as uuid11 } from "uuid";
|
|
7446
|
+
var definitionRoutes = new Hono14();
|
|
6280
7447
|
definitionRoutes.get("/", async (c) => {
|
|
6281
7448
|
const nestId = c.req.param("nestId");
|
|
6282
7449
|
const db = getDb();
|
|
@@ -6355,7 +7522,7 @@ definitionRoutes.post("/", async (c) => {
|
|
|
6355
7522
|
await applyUpdate(tx, existing.id);
|
|
6356
7523
|
targetId = existing.id;
|
|
6357
7524
|
} else {
|
|
6358
|
-
targetId =
|
|
7525
|
+
targetId = uuid11();
|
|
6359
7526
|
wasCreated = true;
|
|
6360
7527
|
await tx.run(
|
|
6361
7528
|
`INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
|
|
@@ -6384,11 +7551,11 @@ definitionRoutes.delete("/:id", async (c) => {
|
|
|
6384
7551
|
});
|
|
6385
7552
|
|
|
6386
7553
|
// src/nests/asset-routes.ts
|
|
6387
|
-
import { Hono as
|
|
6388
|
-
import { v4 as
|
|
6389
|
-
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
6390
|
-
import { join as
|
|
6391
|
-
var assetRoutes = new
|
|
7554
|
+
import { Hono as Hono15 } from "hono";
|
|
7555
|
+
import { v4 as uuid12 } from "uuid";
|
|
7556
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
7557
|
+
import { join as join4 } from "path";
|
|
7558
|
+
var assetRoutes = new Hono15();
|
|
6392
7559
|
var CONTENT_TYPES = {
|
|
6393
7560
|
png: "image/png",
|
|
6394
7561
|
jpg: "image/jpeg",
|
|
@@ -6410,10 +7577,10 @@ assetRoutes.post("/", async (c) => {
|
|
|
6410
7577
|
`unsupported image type ".${ext}" \u2014 allowed: ${Object.keys(CONTENT_TYPES).join(", ")}`
|
|
6411
7578
|
);
|
|
6412
7579
|
}
|
|
6413
|
-
const name = `${
|
|
6414
|
-
const dir =
|
|
6415
|
-
await
|
|
6416
|
-
await writeFile(
|
|
7580
|
+
const name = `${uuid12()}.${ext === "jpeg" ? "jpg" : ext}`;
|
|
7581
|
+
const dir = join4(resolveNestPath(nestId), "assets");
|
|
7582
|
+
await mkdir2(dir, { recursive: true });
|
|
7583
|
+
await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
|
|
6417
7584
|
return c.json(
|
|
6418
7585
|
{
|
|
6419
7586
|
file: name,
|
|
@@ -6431,7 +7598,7 @@ assetRoutes.get("/:file", async (c) => {
|
|
|
6431
7598
|
}
|
|
6432
7599
|
let bytes;
|
|
6433
7600
|
try {
|
|
6434
|
-
bytes = await readFile2(
|
|
7601
|
+
bytes = await readFile2(join4(resolveNestPath(nestId), "assets", name));
|
|
6435
7602
|
} catch {
|
|
6436
7603
|
throw new NotFoundError("Asset not found");
|
|
6437
7604
|
}
|
|
@@ -6444,7 +7611,7 @@ assetRoutes.get("/:file", async (c) => {
|
|
|
6444
7611
|
});
|
|
6445
7612
|
|
|
6446
7613
|
// src/mcp/server-routes.ts
|
|
6447
|
-
import { Hono as
|
|
7614
|
+
import { Hono as Hono16 } from "hono";
|
|
6448
7615
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6449
7616
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6450
7617
|
import { z as z2 } from "zod";
|
|
@@ -6685,7 +7852,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
6685
7852
|
}
|
|
6686
7853
|
return server;
|
|
6687
7854
|
}
|
|
6688
|
-
var serverMcpRoutes = new
|
|
7855
|
+
var serverMcpRoutes = new Hono16();
|
|
6689
7856
|
serverMcpRoutes.all("/", async (c) => {
|
|
6690
7857
|
const userId = c.get("userId");
|
|
6691
7858
|
const userEmail = await getUserEmail3(userId);
|
|
@@ -6707,7 +7874,7 @@ serverMcpRoutes.all("/", async (c) => {
|
|
|
6707
7874
|
await server.server.close();
|
|
6708
7875
|
}
|
|
6709
7876
|
});
|
|
6710
|
-
var nestIndexRoutes = new
|
|
7877
|
+
var nestIndexRoutes = new Hono16();
|
|
6711
7878
|
nestIndexRoutes.get("/", async (c) => {
|
|
6712
7879
|
const userId = c.get("userId");
|
|
6713
7880
|
const nests = await buildNestIndex(userId, c.get("nestScope"));
|
|
@@ -6829,17 +7996,17 @@ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
|
|
|
6829
7996
|
through steward review before they become AI-visible.
|
|
6830
7997
|
`;
|
|
6831
7998
|
}
|
|
6832
|
-
var llmsTxtRoutes = new
|
|
7999
|
+
var llmsTxtRoutes = new Hono16();
|
|
6833
8000
|
llmsTxtRoutes.get("/", (c) => {
|
|
6834
8001
|
const md = renderLlmsTxt(requestBaseUrl(c.req.url));
|
|
6835
8002
|
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
6836
8003
|
});
|
|
6837
8004
|
|
|
6838
8005
|
// src/governance/routes.ts
|
|
6839
|
-
import { Hono as
|
|
8006
|
+
import { Hono as Hono17 } from "hono";
|
|
6840
8007
|
|
|
6841
8008
|
// src/governance/comment-service.ts
|
|
6842
|
-
import { v4 as
|
|
8009
|
+
import { v4 as uuid13 } from "uuid";
|
|
6843
8010
|
async function createComment(params) {
|
|
6844
8011
|
const db = getDb();
|
|
6845
8012
|
const body = (params.body ?? "").trim();
|
|
@@ -6855,7 +8022,7 @@ async function createComment(params) {
|
|
|
6855
8022
|
throw new Error("Parent comment not found on this node");
|
|
6856
8023
|
}
|
|
6857
8024
|
}
|
|
6858
|
-
const id =
|
|
8025
|
+
const id = uuid13();
|
|
6859
8026
|
await db.run(
|
|
6860
8027
|
`INSERT INTO comments
|
|
6861
8028
|
(id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
|
|
@@ -7055,7 +8222,7 @@ function rowToComment(row) {
|
|
|
7055
8222
|
|
|
7056
8223
|
// src/governance/stewards-parser.ts
|
|
7057
8224
|
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
7058
|
-
import { join as
|
|
8225
|
+
import { join as join5 } from "path";
|
|
7059
8226
|
function parseStewardsYaml(content) {
|
|
7060
8227
|
const result = { version: 1 };
|
|
7061
8228
|
const lines = content.split("\n");
|
|
@@ -7121,9 +8288,9 @@ function parseEntry(str) {
|
|
|
7121
8288
|
function loadStewardsConfig(nestId) {
|
|
7122
8289
|
const nestPath = resolveNestPath(nestId);
|
|
7123
8290
|
const candidates = [
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
8291
|
+
join5(nestPath, "stewards.yaml"),
|
|
8292
|
+
join5(nestPath, "stewards.yml"),
|
|
8293
|
+
join5(nestPath, ".context", "stewards.yaml")
|
|
7127
8294
|
];
|
|
7128
8295
|
for (const candidatePath of candidates) {
|
|
7129
8296
|
if (existsSync(candidatePath)) {
|
|
@@ -7140,7 +8307,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
|
|
|
7140
8307
|
if (Number.isNaN(n) || n < 1) return def;
|
|
7141
8308
|
return Math.min(n, max);
|
|
7142
8309
|
}
|
|
7143
|
-
var governanceRoutes = new
|
|
8310
|
+
var governanceRoutes = new Hono17();
|
|
7144
8311
|
governanceRoutes.get("/stewards", async (c) => {
|
|
7145
8312
|
const nestId = c.req.param("nestId");
|
|
7146
8313
|
const scope = c.req.query("scope");
|
|
@@ -7278,7 +8445,7 @@ governanceRoutes.get("/activity", async (c) => {
|
|
|
7278
8445
|
const activity = await getActivity({ nestId, limit });
|
|
7279
8446
|
return c.json({ activity });
|
|
7280
8447
|
});
|
|
7281
|
-
var governanceNodeRoutes = new
|
|
8448
|
+
var governanceNodeRoutes = new Hono17();
|
|
7282
8449
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
7283
8450
|
const nestId = c.req.param("nestId");
|
|
7284
8451
|
const nodeId = c.req.param("nodeId");
|
|
@@ -7336,6 +8503,10 @@ governanceNodeRoutes.get("/:nodeId{.+}/comments", async (c) => {
|
|
|
7336
8503
|
governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
7337
8504
|
const nestId = c.req.param("nestId");
|
|
7338
8505
|
const nodeId = c.req.param("nodeId");
|
|
8506
|
+
const annotationReply = nodeId.match(/^(.+)\/annotations\/([^/]+)$/);
|
|
8507
|
+
if (annotationReply) {
|
|
8508
|
+
return handleAnnotationReply(c, annotationReply[1], annotationReply[2]);
|
|
8509
|
+
}
|
|
7339
8510
|
const body = await c.req.json();
|
|
7340
8511
|
const author = await getUserEmail4(c);
|
|
7341
8512
|
try {
|
|
@@ -7356,7 +8527,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
|
7356
8527
|
}
|
|
7357
8528
|
});
|
|
7358
8529
|
governanceNodeRoutes.post(
|
|
7359
|
-
"/:nodeId{
|
|
8530
|
+
"/:nodeId{.+}/comments/:commentId/resolve",
|
|
7360
8531
|
async (c) => {
|
|
7361
8532
|
const nestId = c.req.param("nestId");
|
|
7362
8533
|
const nodeId = c.req.param("nodeId");
|
|
@@ -7487,14 +8658,14 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
|
7487
8658
|
const userEmail = await getUserEmail4(c);
|
|
7488
8659
|
return c.json(await canUserEdit(nestId, nodeId, userEmail));
|
|
7489
8660
|
});
|
|
7490
|
-
governanceNodeRoutes.get("/:nodeId{
|
|
8661
|
+
governanceNodeRoutes.get("/:nodeId{.+}/external-edits", async (c) => {
|
|
7491
8662
|
const nestId = c.req.param("nestId");
|
|
7492
8663
|
const nodeId = c.req.param("nodeId");
|
|
7493
8664
|
const pending = await getPendingChange(nestId, nodeId);
|
|
7494
8665
|
return c.json({ pending });
|
|
7495
8666
|
});
|
|
7496
8667
|
governanceNodeRoutes.get(
|
|
7497
|
-
"/:nodeId{
|
|
8668
|
+
"/:nodeId{.+}/external-edits/:suggestionId",
|
|
7498
8669
|
async (c) => {
|
|
7499
8670
|
const nestId = c.req.param("nestId");
|
|
7500
8671
|
const nodeId = c.req.param("nodeId");
|
|
@@ -7511,7 +8682,7 @@ governanceNodeRoutes.get(
|
|
|
7511
8682
|
}
|
|
7512
8683
|
);
|
|
7513
8684
|
governanceNodeRoutes.post(
|
|
7514
|
-
"/:nodeId{
|
|
8685
|
+
"/:nodeId{.+}/external-edits/:suggestionId/approve",
|
|
7515
8686
|
async (c) => {
|
|
7516
8687
|
const nestId = c.req.param("nestId");
|
|
7517
8688
|
const nodeId = c.req.param("nodeId");
|
|
@@ -7544,7 +8715,7 @@ governanceNodeRoutes.post(
|
|
|
7544
8715
|
}
|
|
7545
8716
|
);
|
|
7546
8717
|
governanceNodeRoutes.post(
|
|
7547
|
-
"/:nodeId{
|
|
8718
|
+
"/:nodeId{.+}/external-edits/:suggestionId/reject",
|
|
7548
8719
|
async (c) => {
|
|
7549
8720
|
const nestId = c.req.param("nestId");
|
|
7550
8721
|
const nodeId = c.req.param("nodeId");
|
|
@@ -7613,12 +8784,19 @@ async function ensureAnonymousUser() {
|
|
|
7613
8784
|
// src/app.ts
|
|
7614
8785
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
7615
8786
|
import { fileURLToPath } from "url";
|
|
7616
|
-
import { dirname, join as
|
|
7617
|
-
import { existsSync as existsSync2 } from "fs";
|
|
7618
|
-
var HERE =
|
|
8787
|
+
import { dirname as dirname2, join as join6, relative as relative2 } from "path";
|
|
8788
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
8789
|
+
var HERE = dirname2(fileURLToPath(import.meta.url));
|
|
8790
|
+
var SERVICE_VERSION = (() => {
|
|
8791
|
+
try {
|
|
8792
|
+
return JSON.parse(readFileSync3(join6(HERE, "..", "package.json"), "utf8")).version;
|
|
8793
|
+
} catch {
|
|
8794
|
+
return "unknown";
|
|
8795
|
+
}
|
|
8796
|
+
})();
|
|
7619
8797
|
var UI_DIR_CANDIDATES = [
|
|
7620
|
-
|
|
7621
|
-
|
|
8798
|
+
join6(HERE, "web3"),
|
|
8799
|
+
join6(process.cwd(), "dist", "web3")
|
|
7622
8800
|
];
|
|
7623
8801
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
7624
8802
|
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
@@ -7655,7 +8833,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
7655
8833
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
7656
8834
|
});
|
|
7657
8835
|
function createApp() {
|
|
7658
|
-
const app = new
|
|
8836
|
+
const app = new Hono18({ router: new LinearRouter() });
|
|
7659
8837
|
const corsOrigins = config.CORS_ORIGINS;
|
|
7660
8838
|
app.use(
|
|
7661
8839
|
"*",
|
|
@@ -7697,10 +8875,17 @@ function createApp() {
|
|
|
7697
8875
|
(c) => c.json({
|
|
7698
8876
|
status: isSuspended() ? "suspended" : "ok",
|
|
7699
8877
|
service: "contextnest-community",
|
|
7700
|
-
version:
|
|
8878
|
+
version: SERVICE_VERSION,
|
|
7701
8879
|
auth_mode: config.AUTH_MODE,
|
|
7702
8880
|
logo_url: config.LOGO_URL,
|
|
8881
|
+
// Presence only, never the value: lets the UI stop prompting for a
|
|
8882
|
+
// runner key once the server-wide one exists.
|
|
8883
|
+
runner_key_configured: !!process.env.ANTHROPIC_API_KEY,
|
|
7703
8884
|
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
8885
|
+
// Manual (email/password) sign-in mode (open | invite-only | disabled).
|
|
8886
|
+
// The login page reads this to show/hide the credential form, and to
|
|
8887
|
+
// switch the Register link to an "activate invite" affordance.
|
|
8888
|
+
manual_sign_in: config.MANUAL_SIGN_IN,
|
|
7704
8889
|
// Public feature flags the SPA needs before auth to decide what to
|
|
7705
8890
|
// render. Mirrors the server-side route gate so UI and API agree:
|
|
7706
8891
|
// flag off → the plane's routes 404 AND its UI stays hidden.
|
|
@@ -7709,6 +8894,12 @@ function createApp() {
|
|
|
7709
8894
|
// must clear a security review — NOT a UI toggle). Exposed read-only so
|
|
7710
8895
|
// the Runs page shows the "spawn sub-agent" option only when it's live.
|
|
7711
8896
|
subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
|
|
8897
|
+
// Recursion bounds (env-overridable in config.ts). Exposed so the Runs
|
|
8898
|
+
// page validates a spawn against the SERVER's real caps instead of
|
|
8899
|
+
// duplicating the defaults — an operator raising/lowering these stays
|
|
8900
|
+
// in sync with the picker's grey-outs.
|
|
8901
|
+
subagent_max_depth: config.SUBAGENT_MAX_DEPTH,
|
|
8902
|
+
subagent_max_children: config.SUBAGENT_MAX_CHILDREN,
|
|
7712
8903
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
7713
8904
|
})
|
|
7714
8905
|
);
|
|
@@ -7779,12 +8970,12 @@ function createApp() {
|
|
|
7779
8970
|
tier: info.tier,
|
|
7780
8971
|
org: info.org,
|
|
7781
8972
|
limits: info.limits,
|
|
7782
|
-
// The key validated and is live now, but if it couldn't be
|
|
7783
|
-
//
|
|
8973
|
+
// The key validated and is live now, but if it couldn't be saved to the
|
|
8974
|
+
// database it'll be lost on restart — surface that instead of pretending
|
|
7784
8975
|
// the setup is durable (the old behavior silently swallowed this).
|
|
7785
8976
|
persisted: info.persisted,
|
|
7786
8977
|
...info.persisted ? {} : {
|
|
7787
|
-
warning: `License validated, but it could not be saved to
|
|
8978
|
+
warning: `License validated, but it could not be saved to the database (${info.persistError || "unknown error"}). It will be LOST on the next restart. Check the database connection, or provide PROMPTOWL_KEY via the container environment.`
|
|
7788
8979
|
}
|
|
7789
8980
|
});
|
|
7790
8981
|
} catch (err) {
|
|
@@ -7796,12 +8987,18 @@ function createApp() {
|
|
|
7796
8987
|
const adminSettingsAllowed = async (c) => config.AUTH_MODE === "open" || await isLicenseAdminUserId(c.get("userId"));
|
|
7797
8988
|
const currentServerSettings = () => ({
|
|
7798
8989
|
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
8990
|
+
manual_sign_in: config.MANUAL_SIGN_IN,
|
|
7799
8991
|
logo_url: config.LOGO_URL,
|
|
7800
8992
|
telemetry_enabled: config.TELEMETRY_ENABLED,
|
|
7801
8993
|
public_base_url: config.PUBLIC_BASE_URL,
|
|
7802
8994
|
max_body_bytes: config.MAX_BODY_BYTES,
|
|
7803
8995
|
workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
|
|
7804
8996
|
subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
|
|
8997
|
+
subagent_max_depth: config.SUBAGENT_MAX_DEPTH,
|
|
8998
|
+
subagent_max_children: config.SUBAGENT_MAX_CHILDREN,
|
|
8999
|
+
trace_retention_days: config.TRACE_RETENTION_DAYS,
|
|
9000
|
+
// Server-wide runner default. NEVER the value — masked tail only.
|
|
9001
|
+
anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
|
|
7805
9002
|
slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
|
|
7806
9003
|
smtp_url: config.SMTP_URL ?? "",
|
|
7807
9004
|
notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
|
|
@@ -7821,14 +9018,23 @@ function createApp() {
|
|
|
7821
9018
|
} catch {
|
|
7822
9019
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
7823
9020
|
}
|
|
7824
|
-
const
|
|
9021
|
+
const fieldErrors = {};
|
|
9022
|
+
const addError = (field, message) => {
|
|
9023
|
+
fieldErrors[field] = message;
|
|
9024
|
+
};
|
|
7825
9025
|
const pending = [];
|
|
7826
9026
|
if ("promptowl_sign_in_gate" in body) {
|
|
7827
9027
|
const v = String(body.promptowl_sign_in_gate ?? "").trim().toLowerCase();
|
|
7828
9028
|
if (!["open", "admin-only", "disabled"].includes(v))
|
|
7829
|
-
|
|
9029
|
+
addError("promptowl_sign_in_gate", "PromptOwl sign-in must be set to Everyone, Admins only, or Off.");
|
|
7830
9030
|
else pending.push({ name: "PROMPTOWL_SIGN_IN_GATE", value: v });
|
|
7831
9031
|
}
|
|
9032
|
+
if ("manual_sign_in" in body) {
|
|
9033
|
+
const v = String(body.manual_sign_in ?? "").trim().toLowerCase();
|
|
9034
|
+
if (!["open", "invite-only", "disabled"].includes(v))
|
|
9035
|
+
addError("manual_sign_in", "Manual sign-in must be set to Everyone, Invite only, or Off.");
|
|
9036
|
+
else pending.push({ name: "MANUAL_SIGN_IN", value: v });
|
|
9037
|
+
}
|
|
7832
9038
|
if ("logo_url" in body) {
|
|
7833
9039
|
const v = String(body.logo_url ?? "").trim();
|
|
7834
9040
|
pending.push({ name: "LOGO_URL", value: v || null });
|
|
@@ -7846,11 +9052,22 @@ function createApp() {
|
|
|
7846
9052
|
value: body.workflow_plane_enabled ? "true" : null
|
|
7847
9053
|
});
|
|
7848
9054
|
}
|
|
9055
|
+
if ("anthropic_api_key" in body) {
|
|
9056
|
+
const v = body.anthropic_api_key;
|
|
9057
|
+
if (v === null || v === "") {
|
|
9058
|
+
pending.push({ name: "ANTHROPIC_API_KEY", value: null });
|
|
9059
|
+
} else if (typeof v !== "string" || !v.trim().startsWith("sk-")) {
|
|
9060
|
+
addError("anthropic_api_key", 'Anthropic API key must start with "sk-" (or be empty to clear it).');
|
|
9061
|
+
} else {
|
|
9062
|
+
pending.push({ name: "ANTHROPIC_API_KEY", value: v.trim() });
|
|
9063
|
+
}
|
|
9064
|
+
}
|
|
7849
9065
|
if ("subagent_runs_enabled" in body) {
|
|
7850
9066
|
const planeOn = "workflow_plane_enabled" in body ? !!body.workflow_plane_enabled : config.FEATURE_WORKFLOW_PLANE;
|
|
7851
9067
|
if (body.subagent_runs_enabled && !planeOn) {
|
|
7852
|
-
|
|
7853
|
-
"subagent_runs_enabled
|
|
9068
|
+
addError(
|
|
9069
|
+
"subagent_runs_enabled",
|
|
9070
|
+
"Sub-agent runs require the Workflow plane to be turned on."
|
|
7854
9071
|
);
|
|
7855
9072
|
} else {
|
|
7856
9073
|
pending.push({
|
|
@@ -7859,41 +9076,103 @@ function createApp() {
|
|
|
7859
9076
|
});
|
|
7860
9077
|
}
|
|
7861
9078
|
}
|
|
9079
|
+
if ("subagent_max_depth" in body) {
|
|
9080
|
+
const v = body.subagent_max_depth;
|
|
9081
|
+
if (v === null || v === "") {
|
|
9082
|
+
pending.push({ name: "SUBAGENT_MAX_DEPTH", value: null });
|
|
9083
|
+
} else {
|
|
9084
|
+
const n = Number(v);
|
|
9085
|
+
if (!Number.isInteger(n) || n < 1 || n > 32)
|
|
9086
|
+
addError("subagent_max_depth", "Max sub-agent depth must be a whole number between 1 and 32.");
|
|
9087
|
+
else pending.push({ name: "SUBAGENT_MAX_DEPTH", value: String(n) });
|
|
9088
|
+
}
|
|
9089
|
+
}
|
|
9090
|
+
if ("subagent_max_children" in body) {
|
|
9091
|
+
const v = body.subagent_max_children;
|
|
9092
|
+
if (v === null || v === "") {
|
|
9093
|
+
pending.push({ name: "SUBAGENT_MAX_CHILDREN", value: null });
|
|
9094
|
+
} else {
|
|
9095
|
+
const n = Number(v);
|
|
9096
|
+
if (!Number.isInteger(n) || n < 1 || n > 128)
|
|
9097
|
+
addError("subagent_max_children", "Max sub-agents per run must be a whole number between 1 and 128.");
|
|
9098
|
+
else pending.push({ name: "SUBAGENT_MAX_CHILDREN", value: String(n) });
|
|
9099
|
+
}
|
|
9100
|
+
}
|
|
9101
|
+
if ("trace_retention_days" in body) {
|
|
9102
|
+
const v = body.trace_retention_days;
|
|
9103
|
+
if (v === null || v === "") {
|
|
9104
|
+
pending.push({ name: "TRACE_RETENTION_DAYS", value: null });
|
|
9105
|
+
} else {
|
|
9106
|
+
const n = Number(v);
|
|
9107
|
+
if (!Number.isInteger(n) || n < 0 || n > 3650)
|
|
9108
|
+
addError("trace_retention_days", "Trace retention must be a whole number of days between 0 and 3650 (0 keeps activity forever).");
|
|
9109
|
+
else pending.push({ name: "TRACE_RETENTION_DAYS", value: String(n) });
|
|
9110
|
+
}
|
|
9111
|
+
}
|
|
7862
9112
|
if ("slack_webhook_url" in body) {
|
|
7863
9113
|
const v = String(body.slack_webhook_url ?? "").trim();
|
|
7864
9114
|
if (v && !/^https:\/\//i.test(v))
|
|
7865
|
-
|
|
9115
|
+
addError("slack_webhook_url", "Slack webhook must be an https:// URL (or leave it empty to turn Slack notifications off).");
|
|
7866
9116
|
else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
|
|
7867
9117
|
}
|
|
7868
9118
|
if ("smtp_url" in body) {
|
|
7869
9119
|
const v = String(body.smtp_url ?? "").trim();
|
|
7870
9120
|
if (v && !/^smtps?:\/\//i.test(v))
|
|
7871
|
-
|
|
9121
|
+
addError("smtp_url", "SMTP connection URL must start with smtp:// or smtps:// (or leave it empty to turn email notifications off).");
|
|
7872
9122
|
else pending.push({ name: "SMTP_URL", value: v || null });
|
|
7873
9123
|
}
|
|
7874
9124
|
if ("notify_email_from" in body) {
|
|
7875
9125
|
const v = String(body.notify_email_from ?? "").trim();
|
|
7876
|
-
if (v &&
|
|
7877
|
-
|
|
9126
|
+
if (v && v.includes(","))
|
|
9127
|
+
addError("notify_email_from", "From address must be a single email address \u2014 put multiple addresses in Recipients (To) instead.");
|
|
9128
|
+
else if (v && !isEmailish(v))
|
|
9129
|
+
addError("notify_email_from", "From address must be a valid email like nest@company.com.");
|
|
7878
9130
|
else pending.push({ name: "NOTIFY_EMAIL_FROM", value: v || null });
|
|
7879
9131
|
}
|
|
7880
9132
|
if ("notify_email_to" in body) {
|
|
7881
9133
|
const v = String(body.notify_email_to ?? "").trim();
|
|
7882
9134
|
if (v && !isEmailListish(v))
|
|
7883
|
-
|
|
9135
|
+
addError("notify_email_to", "Recipients must be valid emails, comma-separated (e.g. team@company.com, ops@company.com).");
|
|
7884
9136
|
else pending.push({ name: "NOTIFY_EMAIL_TO", value: v || null });
|
|
7885
9137
|
}
|
|
7886
9138
|
if ("max_body_bytes" in body) {
|
|
7887
9139
|
const n = Number(body.max_body_bytes);
|
|
7888
9140
|
if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
|
|
7889
|
-
|
|
9141
|
+
addError("max_body_bytes", "Max upload size must be between 1 MB and 500 MB.");
|
|
7890
9142
|
else pending.push({ name: "MAX_BODY_BYTES", value: String(Math.floor(n)) });
|
|
7891
9143
|
}
|
|
7892
|
-
|
|
7893
|
-
|
|
9144
|
+
const effManual = "manual_sign_in" in body ? String(body.manual_sign_in ?? "").trim().toLowerCase() : config.MANUAL_SIGN_IN;
|
|
9145
|
+
const effGate = "promptowl_sign_in_gate" in body ? String(body.promptowl_sign_in_gate ?? "").trim().toLowerCase() : config.PROMPTOWL_SIGN_IN_GATE;
|
|
9146
|
+
if (effManual === "disabled" && effGate === "disabled") {
|
|
9147
|
+
addError(
|
|
9148
|
+
"manual_sign_in",
|
|
9149
|
+
"Cannot disable both manual and PromptOwl sign-in \u2014 no one would be able to log in."
|
|
9150
|
+
);
|
|
9151
|
+
}
|
|
9152
|
+
const errorMessages = Object.values(fieldErrors);
|
|
9153
|
+
if (errorMessages.length)
|
|
9154
|
+
return c.json(
|
|
9155
|
+
{ error: errorMessages.join("; "), fieldErrors, settings: currentServerSettings() },
|
|
9156
|
+
400
|
|
9157
|
+
);
|
|
9158
|
+
const smtpChange = pending.find((p) => p.name === "SMTP_URL");
|
|
9159
|
+
if (smtpChange && smtpChange.value) {
|
|
9160
|
+
const result = await verifySmtp(smtpChange.value);
|
|
9161
|
+
if (!result.ok) {
|
|
9162
|
+
const message = `SMTP connection failed: ${result.error}`;
|
|
9163
|
+
return c.json(
|
|
9164
|
+
{
|
|
9165
|
+
error: message,
|
|
9166
|
+
fieldErrors: { smtp_url: message },
|
|
9167
|
+
settings: currentServerSettings()
|
|
9168
|
+
},
|
|
9169
|
+
400
|
|
9170
|
+
);
|
|
9171
|
+
}
|
|
9172
|
+
}
|
|
7894
9173
|
for (const { name, value } of pending) {
|
|
7895
9174
|
try {
|
|
7896
|
-
|
|
9175
|
+
await persistSetting(name, value);
|
|
7897
9176
|
} catch (e) {
|
|
7898
9177
|
return c.json(
|
|
7899
9178
|
{
|
|
@@ -7931,6 +9210,45 @@ function createApp() {
|
|
|
7931
9210
|
});
|
|
7932
9211
|
return c.json({ count: events.length, total, limit, offset, events });
|
|
7933
9212
|
});
|
|
9213
|
+
app.use("/me/*", flexAuthMiddleware);
|
|
9214
|
+
app.get("/me/work", async (c) => {
|
|
9215
|
+
const userId = c.get("userId");
|
|
9216
|
+
const me = await resolveCallerEmail(userId);
|
|
9217
|
+
const nestName2 = /* @__PURE__ */ new Map();
|
|
9218
|
+
for (const n of [
|
|
9219
|
+
...await listNests(userId),
|
|
9220
|
+
...await listSharedNests(userId)
|
|
9221
|
+
])
|
|
9222
|
+
nestName2.set(n.id, n.name ?? n.id);
|
|
9223
|
+
const { requests } = await getReviewQueue({ status: "pending", limit: 200 });
|
|
9224
|
+
const toItem = (r) => ({
|
|
9225
|
+
type: "review",
|
|
9226
|
+
nest_id: r.nestId,
|
|
9227
|
+
nest_name: nestName2.get(r.nestId),
|
|
9228
|
+
node_id: r.nodeId,
|
|
9229
|
+
title: r.title || r.nodeId,
|
|
9230
|
+
requested_by: r.requestedBy,
|
|
9231
|
+
requested_at: r.requestedAt,
|
|
9232
|
+
priority: r.priority
|
|
9233
|
+
});
|
|
9234
|
+
const mine = requests.filter((r) => nestName2.has(r.nestId));
|
|
9235
|
+
const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
|
|
9236
|
+
const items = mine.filter((r) => r.requestedBy !== me).map(toItem).sort(byNewest);
|
|
9237
|
+
const waiting = mine.filter((r) => r.requestedBy === me).map(toItem).sort(byNewest);
|
|
9238
|
+
const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
|
|
9239
|
+
return c.json({ items, waiting, notifications });
|
|
9240
|
+
});
|
|
9241
|
+
app.get("/me/notifications", async (c) => {
|
|
9242
|
+
const me = await resolveCallerEmail(c.get("userId"));
|
|
9243
|
+
const unreadOnly = c.req.query("unread") === "true";
|
|
9244
|
+
return c.json({ notifications: await listNotifications(me, { unreadOnly }) });
|
|
9245
|
+
});
|
|
9246
|
+
app.post("/me/notifications/read", async (c) => {
|
|
9247
|
+
const me = await resolveCallerEmail(c.get("userId"));
|
|
9248
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9249
|
+
await markNotificationsRead(me, body.ids === "all" ? "all" : body.ids ?? []);
|
|
9250
|
+
return c.json({ ok: true });
|
|
9251
|
+
});
|
|
7934
9252
|
app.use("/stats", flexAuthMiddleware);
|
|
7935
9253
|
app.get("/stats", async (c) => {
|
|
7936
9254
|
const db = getDb();
|
|
@@ -7959,12 +9277,13 @@ function createApp() {
|
|
|
7959
9277
|
});
|
|
7960
9278
|
app.use("/runs/*", flexAuthMiddleware);
|
|
7961
9279
|
app.route("/runs", runDetailRoutes);
|
|
9280
|
+
app.route("/hooks", hookFireRoutes);
|
|
7962
9281
|
app.route("/llms.txt", llmsTxtRoutes);
|
|
7963
9282
|
app.use("/index", flexAuthMiddleware);
|
|
7964
9283
|
app.route("/index", nestIndexRoutes);
|
|
7965
9284
|
app.use("/mcp", flexAuthMiddleware);
|
|
7966
9285
|
app.route("/mcp", serverMcpRoutes);
|
|
7967
|
-
const nestsApp = new
|
|
9286
|
+
const nestsApp = new Hono18();
|
|
7968
9287
|
nestsApp.use("*", flexAuthMiddleware);
|
|
7969
9288
|
nestsApp.use("*", async (c, next) => {
|
|
7970
9289
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
@@ -8053,15 +9372,17 @@ function createApp() {
|
|
|
8053
9372
|
required = "admin";
|
|
8054
9373
|
} else if (resource === "collaborators") {
|
|
8055
9374
|
required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
|
|
9375
|
+
} else if (parts[1] === "hooks") {
|
|
9376
|
+
required = "write";
|
|
8056
9377
|
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery) {
|
|
8057
9378
|
required = "write";
|
|
8058
9379
|
}
|
|
8059
|
-
const
|
|
9380
|
+
const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
|
|
8060
9381
|
let stewardEditorBypass = false;
|
|
8061
9382
|
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
8062
9383
|
const userEmail = await resolveCallerEmail(userId);
|
|
8063
|
-
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" ||
|
|
8064
|
-
const idParts =
|
|
9384
|
+
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeAction)) {
|
|
9385
|
+
const idParts = isNodeAction ? parts.slice(2, -1) : parts.slice(2);
|
|
8065
9386
|
const rawNodeId = idParts.join("/");
|
|
8066
9387
|
let nodeId = rawNodeId;
|
|
8067
9388
|
try {
|
|
@@ -8101,6 +9422,30 @@ function createApp() {
|
|
|
8101
9422
|
assertSafeNodeId(c.req.param("nodeId"));
|
|
8102
9423
|
return next();
|
|
8103
9424
|
});
|
|
9425
|
+
nestsApp.get("/:nestId/trace", async (c) => {
|
|
9426
|
+
const nestId = c.req.param("nestId");
|
|
9427
|
+
if (!await canManageStewards(nestId, c.get("userId"))) {
|
|
9428
|
+
return c.json(
|
|
9429
|
+
{
|
|
9430
|
+
error: "Only the nest owner, a nest admin, or the server admin can view this nest's activity."
|
|
9431
|
+
},
|
|
9432
|
+
403
|
|
9433
|
+
);
|
|
9434
|
+
}
|
|
9435
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
9436
|
+
const offsetRaw = Number(c.req.query("offset"));
|
|
9437
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
|
|
9438
|
+
const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
|
|
9439
|
+
const { events, total } = await listTraceEvents({
|
|
9440
|
+
limit,
|
|
9441
|
+
offset,
|
|
9442
|
+
kind: c.req.query("kind"),
|
|
9443
|
+
nestId,
|
|
9444
|
+
// pinned — the caller cannot query outside their nest
|
|
9445
|
+
user: c.req.query("user") || void 0
|
|
9446
|
+
});
|
|
9447
|
+
return c.json({ count: events.length, total, limit, offset, events });
|
|
9448
|
+
});
|
|
8104
9449
|
nestsApp.route("/", nestRoutes);
|
|
8105
9450
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
8106
9451
|
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
@@ -8113,12 +9458,29 @@ function createApp() {
|
|
|
8113
9458
|
nestsApp.route("/:nestId/edges", edgeRoutes);
|
|
8114
9459
|
nestsApp.route("/:nestId/run", runTriggerRoutes);
|
|
8115
9460
|
nestsApp.route("/:nestId/runs", runListRoutes);
|
|
9461
|
+
nestsApp.route("/:nestId/schedules", scheduleRoutes);
|
|
9462
|
+
nestsApp.route("/:nestId/env", envRoutes);
|
|
9463
|
+
nestsApp.route("/:nestId/connectors", connectorRoutes);
|
|
9464
|
+
nestsApp.route("/:nestId/hooks", hookNestRoutes);
|
|
8116
9465
|
nestsApp.route("/:nestId/definitions", definitionRoutes);
|
|
8117
9466
|
nestsApp.route("/:nestId/assets", assetRoutes);
|
|
8118
9467
|
nestsApp.route("/:nestId/mcp", mcpRoutes);
|
|
8119
9468
|
app.route("/nests", nestsApp);
|
|
8120
|
-
app.use(
|
|
8121
|
-
|
|
9469
|
+
app.use(
|
|
9470
|
+
"/assets/*",
|
|
9471
|
+
serveStatic({
|
|
9472
|
+
root: UI_DIR_REL,
|
|
9473
|
+
onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
|
|
9474
|
+
})
|
|
9475
|
+
);
|
|
9476
|
+
app.get(
|
|
9477
|
+
"*",
|
|
9478
|
+
serveStatic({
|
|
9479
|
+
root: UI_DIR_REL,
|
|
9480
|
+
path: "index.html",
|
|
9481
|
+
onFound: (_p, c) => c.header("Cache-Control", "no-cache")
|
|
9482
|
+
})
|
|
9483
|
+
);
|
|
8122
9484
|
app.onError((err, c) => {
|
|
8123
9485
|
if (err instanceof AppError) {
|
|
8124
9486
|
return c.json({ error: err.message }, err.statusCode);
|
|
@@ -8292,9 +9654,77 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
8292
9654
|
);
|
|
8293
9655
|
}
|
|
8294
9656
|
|
|
9657
|
+
// src/workflow/scheduler.ts
|
|
9658
|
+
import { v4 as uuid14 } from "uuid";
|
|
9659
|
+
function isDue(s, now) {
|
|
9660
|
+
if (!s.last_run_at) return true;
|
|
9661
|
+
const last = Date.parse(s.last_run_at);
|
|
9662
|
+
if (Number.isNaN(last)) return true;
|
|
9663
|
+
return now - last >= s.every_minutes * 6e4;
|
|
9664
|
+
}
|
|
9665
|
+
async function schedulerTick(now = Date.now()) {
|
|
9666
|
+
const db = getDb();
|
|
9667
|
+
const due = (await db.all("SELECT * FROM schedules WHERE enabled = 1")).filter((s) => isDue(s, now));
|
|
9668
|
+
const created = [];
|
|
9669
|
+
for (const s of due) {
|
|
9670
|
+
try {
|
|
9671
|
+
const { n } = await db.get(
|
|
9672
|
+
`SELECT COUNT(*) AS n FROM runs
|
|
9673
|
+
WHERE nest_id = ? AND parent_run_id IS NULL AND status = 'running'`,
|
|
9674
|
+
[s.nest_id]
|
|
9675
|
+
);
|
|
9676
|
+
if (n >= config.RUN_MAX_CONCURRENT_ROOTS) {
|
|
9677
|
+
console.warn(
|
|
9678
|
+
`[scheduler] skip ${s.agent_node} in ${s.nest_id}: ${n} runs already running`
|
|
9679
|
+
);
|
|
9680
|
+
continue;
|
|
9681
|
+
}
|
|
9682
|
+
const id = `run_${uuid14()}`;
|
|
9683
|
+
const nowIso = new Date(now).toISOString();
|
|
9684
|
+
await db.run(
|
|
9685
|
+
`INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
|
|
9686
|
+
VALUES (?, ?, ?, ?, 'running', NULL, ?, NULL, 0)`,
|
|
9687
|
+
// triggered_by names the schedule's creator with a schedule: prefix —
|
|
9688
|
+
// honest provenance ("this fired on X's schedule, not X clicking").
|
|
9689
|
+
[id, s.nest_id, s.agent_node, `schedule:${s.created_by}`, nowIso]
|
|
9690
|
+
);
|
|
9691
|
+
const fired = s.runs_created + 1;
|
|
9692
|
+
const done = s.max_runs !== null && fired >= s.max_runs;
|
|
9693
|
+
await db.run(
|
|
9694
|
+
`UPDATE schedules SET last_run_at = ?, runs_created = ?, enabled = ?, updated_at = ?
|
|
9695
|
+
WHERE id = ?`,
|
|
9696
|
+
[nowIso, fired, done ? 0 : s.enabled, nowIso, s.id]
|
|
9697
|
+
);
|
|
9698
|
+
created.push(id);
|
|
9699
|
+
} catch (err) {
|
|
9700
|
+
console.error(`[scheduler] failed to fire schedule ${s.id}`, err);
|
|
9701
|
+
}
|
|
9702
|
+
}
|
|
9703
|
+
return created;
|
|
9704
|
+
}
|
|
9705
|
+
var timer = null;
|
|
9706
|
+
function startScheduler() {
|
|
9707
|
+
if (!config.FEATURE_WORKFLOW_PLANE || timer) return;
|
|
9708
|
+
timer = setInterval(() => {
|
|
9709
|
+
void schedulerTick().catch(
|
|
9710
|
+
(err) => console.error("[scheduler] tick failed", err)
|
|
9711
|
+
);
|
|
9712
|
+
}, 6e4);
|
|
9713
|
+
timer.unref?.();
|
|
9714
|
+
console.log("[scheduler] workflow schedules active (60s tick)");
|
|
9715
|
+
}
|
|
9716
|
+
|
|
8295
9717
|
// src/index.ts
|
|
8296
9718
|
async function main() {
|
|
8297
9719
|
const db = await initDb();
|
|
9720
|
+
try {
|
|
9721
|
+
const loaded = await loadServerSettings();
|
|
9722
|
+
if (loaded > 0) {
|
|
9723
|
+
console.log(` Loaded ${loaded} persisted server setting(s) from the database`);
|
|
9724
|
+
}
|
|
9725
|
+
} catch (err) {
|
|
9726
|
+
console.error("[settings] failed to load persisted server settings:", err);
|
|
9727
|
+
}
|
|
8298
9728
|
try {
|
|
8299
9729
|
await backfillNodeVersionsFromHistory(db);
|
|
8300
9730
|
} catch (err) {
|
|
@@ -8384,6 +9814,8 @@ ${config.AUTH_MODE === "key" ? `
|
|
|
8384
9814
|
`);
|
|
8385
9815
|
}
|
|
8386
9816
|
serve({ fetch: app.fetch, port: config.PORT });
|
|
9817
|
+
startScheduler();
|
|
9818
|
+
setInterval(() => sweepStale(), 10 * 6e4).unref?.();
|
|
8387
9819
|
}
|
|
8388
9820
|
main().catch((err) => {
|
|
8389
9821
|
console.error("Fatal:", err);
|