@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/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
- submitForReview
12
- } from "./chunk-VC3QNHHB.js";
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-TIO5XOFD.js";
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-HRQWNRVI.js";
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-BYS4HDME.js";
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-DPHV6Q26.js";
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 Hono16 } from "hono";
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
- let userId;
469
- const passwordHash = await hashPassword(body.password);
470
- if (existing && existing.is_invited === 1) {
471
- userId = existing.id;
472
- await db.run(
473
- "UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?",
474
- [passwordHash, body.name || null, userId]
475
- );
476
- trackEvent("user.register", { userId, email, claimed: true });
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: { "Content-Type": "application/json" },
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 { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
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-6J4OWQL6.js");
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("SELECT id FROM users WHERE LOWER(email) = LOWER(?)", [email]);
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(doc.id, approved);
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("reconstructVersion failed (list)", doc.id, approved, err);
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 ?? "").split("/").map(slugify).filter(Boolean);
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("each folder or title segment must be at most 100 characters");
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("safePublishDocument failed (import register)", nodeId, err);
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
- const { storage, versions: versionManager } = await engineCache.get(nestId);
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 (patch.content !== void 0) {
1643
- node = { ...node, body: patch.content };
1644
- }
1645
- if (patch.append) {
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
- if (patch.status) {
1654
- node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
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
- if (patch.title) {
1657
- node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
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
- if (patch.schedule !== void 0) {
1660
- if (!isRunnableType(node.frontmatter.type)) {
1661
- throw new ValidationError(
1662
- `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
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
- const metadata = { ...node.frontmatter.metadata };
1666
- if (patch.schedule === "") delete metadata.schedule;
1667
- else metadata.schedule = patch.schedule;
1668
- node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
1669
- }
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
- let responseVersion;
1678
- if (hasStewards) {
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 join2, relative, resolve } from "path";
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 = join2(dir, e.name);
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}`, join2(abs, 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 = join2(root, e.name);
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 = join2(dir, e.name);
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 = join2(config.DATA_ROOT, folderName);
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("/import", async (c) => {
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
- nestRoutes.get("/unsynced", async (c) => {
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("/unsynced/sync", async (c) => {
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{.+?}/stewards", async (c) => {
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-3KCVVXCW.js");
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{.+?}/versions", async (c) => {
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-6J4OWQL6.js");
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{.+?}/reviews", async (c) => {
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-A3XFQTJH.js");
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
- annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/comments", async (c) => {
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/nodes/prompt-compiler.ts
3213
- var STOPWORDS = /* @__PURE__ */ new Set([
3214
- "the",
3215
- "a",
3216
- "an",
3217
- "and",
3218
- "or",
3219
- "but",
3220
- "if",
3221
- "then",
3222
- "else",
3223
- "so",
3224
- "as",
3225
- "is",
3226
- "are",
3227
- "was",
3228
- "were",
3229
- "be",
3230
- "been",
3231
- "being",
3232
- "have",
3233
- "has",
3234
- "had",
3235
- "do",
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 Hono10 } from "hono";
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 cutoff = new Date(
4109
- Date.now() - RETENTION_DAYS * 864e5
4110
- ).toISOString();
4111
- await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
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 uuid8 } from "uuid";
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 Hono9();
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 = uuid7();
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 = uuid8();
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
- [uuid8(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
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-MDLRPMZR.js");
6067
- const db = (await import("./client-GI74NPIW.js")).getDb();
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: uuid12 } = await import("uuid");
6075
- const id = uuid12();
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(uuid12())]
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 Hono10();
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 Hono11 } from "hono";
6200
- var runTriggerRoutes = new Hono11();
6201
- runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
6202
- const nestId = c.req.param("nestId");
6203
- const agentNode = assertSafeNodeId(c.req.param("agentNode"));
6204
- const body = await c.req.json().catch(() => ({}));
6205
- const { storage } = await engineCache.get(nestId);
6206
- const triggeredBy = await resolveCallerEmail(c.get("userId"));
6207
- const bundle = await triggerRun({
6208
- nestId,
6209
- agentNode,
6210
- inputs: body.inputs,
6211
- triggeredBy,
6212
- storage,
6213
- parentRunId: body.parent_run_id
6214
- });
6215
- return c.json(bundle, 201);
6216
- });
6217
- var runListRoutes = new Hono11();
6218
- runListRoutes.get("/", requireWorkflowPlane, async (c) => {
6219
- const nestId = c.req.param("nestId");
6220
- const canSeeDetail = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
6221
- const result = await listRuns(nestId, {
6222
- agent: c.req.query("agent") || void 0,
6223
- status: c.req.query("status") || void 0,
6224
- limit: Number(c.req.query("limit")) || void 0,
6225
- canSeeDetail
6226
- });
6227
- return c.json(result);
6228
- });
6229
- var runDetailRoutes = new Hono11();
6230
- async function loadRunWithPermission(c, runId, required) {
6231
- const run = await loadRun(runId);
6232
- if (!run) throw new NotFoundError("Run not found");
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 AppError(503, `Server suspended by PromptOwl: ${getSuspensionReason()}`);
6873
+ throw new ForbiddenError(
6874
+ `Server suspended by PromptOwl: ${getSuspensionReason()}`
6875
+ );
6240
6876
  }
6241
6877
  if (!getCurrentLicense()?.valid) {
6242
- throw new AppError(503, "A valid PromptOwl license is required to write runs.");
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 ForbiddenError(
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
- const { seq } = await appendRunStep(run.id, body);
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 { status } = await closeRun(run.id, body);
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 result = await getRunTrace(run, { canSeeDetail });
6273
- return c.json(result);
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/definitions/routes.ts
6277
- import { Hono as Hono12 } from "hono";
7003
+ // src/workflow/schedule-routes.ts
7004
+ import { Hono as Hono11 } from "hono";
6278
7005
  import { v4 as uuid9 } from "uuid";
6279
- var definitionRoutes = new Hono12();
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 = uuid9();
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 Hono13 } from "hono";
6388
- import { v4 as uuid10 } from "uuid";
6389
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
6390
- import { join as join3 } from "path";
6391
- var assetRoutes = new Hono13();
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 = `${uuid10()}.${ext === "jpeg" ? "jpg" : ext}`;
6414
- const dir = join3(resolveNestPath(nestId), "assets");
6415
- await mkdir(dir, { recursive: true });
6416
- await writeFile(join3(dir, name), Buffer.from(await file.arrayBuffer()));
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(join3(resolveNestPath(nestId), "assets", name));
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 Hono14 } from "hono";
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 Hono14();
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 Hono14();
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 Hono14();
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 Hono15 } from "hono";
8006
+ import { Hono as Hono17 } from "hono";
6840
8007
 
6841
8008
  // src/governance/comment-service.ts
6842
- import { v4 as uuid11 } from "uuid";
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 = uuid11();
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 join4 } from "path";
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
- join4(nestPath, "stewards.yaml"),
7125
- join4(nestPath, "stewards.yml"),
7126
- join4(nestPath, ".context", "stewards.yaml")
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 Hono15();
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 Hono15();
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{.+?}/comments/:commentId/resolve",
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{.+?}/external-edits", async (c) => {
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{.+?}/external-edits/:suggestionId",
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{.+?}/external-edits/:suggestionId/approve",
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{.+?}/external-edits/:suggestionId/reject",
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 join5, relative as relative2 } from "path";
7617
- import { existsSync as existsSync2 } from "fs";
7618
- var HERE = dirname(fileURLToPath(import.meta.url));
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
- join5(HERE, "web3"),
7621
- join5(process.cwd(), "dist", "web3")
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 Hono16({ router: new LinearRouter() });
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: "0.1.0",
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 written to
7783
- // disk it'll be lost on restart — surface that instead of pretending
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 disk (${info.persistError || "unknown error"}). It will be LOST on the next restart. Set ENV_FILE_PATH to a writable, persisted path (e.g. under DATA_ROOT) or provide PROMPTOWL_KEY via the container environment.`
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 errors = [];
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
- errors.push("promptowl_sign_in_gate must be open | admin-only | disabled");
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
- errors.push(
7853
- "subagent_runs_enabled requires workflow_plane_enabled to be on"
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
- errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
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
- errors.push("smtp_url must be an smtp:// or smtps:// URL (or empty to disable)");
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 && !isEmailish(v))
7877
- errors.push("notify_email_from must be a plain email address");
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
- errors.push("notify_email_to must be a comma-separated list of email addresses");
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
- errors.push("max_body_bytes must be between 1MB and 500MB");
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
- if (errors.length)
7893
- return c.json({ error: errors.join("; "), settings: currentServerSettings() }, 400);
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
- upsertEnvVar(config.ENV_FILE_PATH, name, value);
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 Hono16();
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 isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
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" || isNodeRevert)) {
8064
- const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
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("/assets/*", serveStatic({ root: UI_DIR_REL }));
8121
- app.get("*", serveStatic({ root: UI_DIR_REL, path: "index.html" }));
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);