@promptowl/contextnest-community 1.17.0 → 1.19.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.
Files changed (29) hide show
  1. package/CONFIGURATION.md +1 -1
  2. package/README.md +6 -2
  3. package/dist/{chunk-YF4OFT2V.js → chunk-3GMGLYTZ.js} +36 -5
  4. package/dist/{chunk-UXJSO54R.js → chunk-BXUVIAMP.js} +59 -14
  5. package/dist/{chunk-SNFGU5Q7.js → chunk-ENDHMQPD.js} +602 -327
  6. package/dist/chunk-F66VCBLA.js +136 -0
  7. package/dist/{chunk-4YGJSUJC.js → chunk-FBDEESNJ.js} +30 -22
  8. package/dist/{chunk-HWDPCPYT.js → chunk-GSYMJ3A4.js} +2 -2
  9. package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
  10. package/dist/chunk-TNAZJYL5.js +379 -0
  11. package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
  12. package/dist/{chunk-GUNJTORH.js → chunk-YVMSM7LS.js} +14 -1
  13. package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
  14. package/dist/engine-V4DY4PYN.js +14 -0
  15. package/dist/external-edit-service-3NK5DGLA.js +33 -0
  16. package/dist/{grants-service-4NXPBYWD.js → grants-service-CADI6LIB.js} +4 -4
  17. package/dist/index.js +580 -513
  18. package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
  19. package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
  20. package/dist/{review-service-3CCKDO5N.js → review-service-LK4M2C43.js} +8 -8
  21. package/dist/{stewardship-service-OHTTZFND.js → stewardship-service-JAFSU5GN.js} +9 -5
  22. package/dist/{version-service-EFZGGL3Y.js → version-service-G7SFFKFO.js} +7 -5
  23. package/dist/web3/assets/index-C26nM5Kn.css +1 -0
  24. package/dist/web3/assets/index-C4oalc2K.js +1065 -0
  25. package/dist/web3/index.html +2 -2
  26. package/package.json +3 -3
  27. package/dist/chunk-XRK6SQSC.js +0 -58
  28. package/dist/web3/assets/index-D1299AIf.css +0 -1
  29. package/dist/web3/assets/index-W6tEA3RK.js +0 -1046
package/dist/index.js CHANGED
@@ -41,7 +41,27 @@ import {
41
41
  submitForReview,
42
42
  withNodeWriteLock,
43
43
  withdrawDeletionRequest
44
- } from "./chunk-4YGJSUJC.js";
44
+ } from "./chunk-FBDEESNJ.js";
45
+ import {
46
+ canCreateInNest,
47
+ canManageStewards,
48
+ canUserAccess,
49
+ canUserApprove,
50
+ canUserEdit,
51
+ createStewardRecord,
52
+ getCollaboratorRole,
53
+ getStewardRolesForUser,
54
+ getStewardsForNest,
55
+ getStewardsForUser,
56
+ listStewards,
57
+ removeSteward,
58
+ resolveGrantRoles,
59
+ resolveStewardsForNode,
60
+ resolveStewardsWithFallback,
61
+ resolveUserRoles,
62
+ syncFromConfig,
63
+ updateSteward
64
+ } from "./chunk-3GMGLYTZ.js";
45
65
  import {
46
66
  createGrant,
47
67
  deleteGrant,
@@ -50,15 +70,16 @@ import {
50
70
  listGrants,
51
71
  listUserGrants,
52
72
  resolveNodeGrant
53
- } from "./chunk-HWDPCPYT.js";
73
+ } from "./chunk-GSYMJ3A4.js";
54
74
  import {
55
75
  generateApiKey,
56
76
  getKeyPrefix,
57
77
  hashApiKey,
58
78
  hashPassword,
59
79
  parseBearerToken,
80
+ verifyKeyRequest,
60
81
  verifyPassword
61
- } from "./chunk-XRK6SQSC.js";
82
+ } from "./chunk-F66VCBLA.js";
62
83
  import {
63
84
  checkConflict,
64
85
  createVersion,
@@ -66,36 +87,32 @@ import {
66
87
  getApprovedVersions,
67
88
  getCurrentVersion,
68
89
  getDisplayStatus,
90
+ getDisplayStatuses,
69
91
  getTrackedNodeIds,
70
92
  getVersions,
71
93
  listMyDrafts,
72
94
  setApprovedVersion,
73
95
  upsertVersion
74
- } from "./chunk-UXJSO54R.js";
96
+ } from "./chunk-BXUVIAMP.js";
75
97
  import {
76
- canCreateInNest,
77
- canManageStewards,
78
- canUserAccess,
79
- canUserApprove,
80
- canUserEdit,
81
- createStewardRecord,
82
- getCollaboratorRole,
83
- getStewardRolesForUser,
84
- getStewardsForNest,
85
- getStewardsForUser,
86
- listStewards,
87
- removeSteward,
88
- resolveGrantRoles,
89
- resolveStewardsForNode,
90
- resolveStewardsWithFallback,
91
- resolveUserRoles,
92
- syncFromConfig,
93
- updateSteward
94
- } from "./chunk-YF4OFT2V.js";
98
+ approveExternalEdit,
99
+ getExternalEditDetail,
100
+ getPendingChange,
101
+ listExternalEditVerdicts,
102
+ listNestExternalEdits,
103
+ loadLatestApprovedNode,
104
+ rejectExternalEdit,
105
+ scanDocumentForDrift,
106
+ scanNestForDrift,
107
+ startDriftScanner
108
+ } from "./chunk-TNAZJYL5.js";
95
109
  import {
96
110
  addMember,
111
+ assertSafeNodeId,
97
112
  buildTitleMap,
98
113
  canApproveWith,
114
+ canEditWith,
115
+ collabPermToRole,
99
116
  createNest,
100
117
  createTeam,
101
118
  deleteNest,
@@ -104,14 +121,17 @@ import {
104
121
  docLink,
105
122
  engineApi,
106
123
  engineCache,
124
+ ensureNodeIndex,
107
125
  findTeamByExternalId,
108
126
  folderForNode,
127
+ folderOfId,
109
128
  getAccessConfig,
110
129
  getCurrentLicense,
111
130
  getNest,
112
131
  getSuspensionReason,
113
132
  getTeam,
114
133
  importNest,
134
+ indexedFolderCounts,
115
135
  insertOrIgnore,
116
136
  installLicenseKey,
117
137
  isConfigSuperAdmin,
@@ -136,11 +156,15 @@ import {
136
156
  nestPrimeTags,
137
157
  nestReviewsPrimeOnly,
138
158
  nestStorageRoot,
159
+ nodesWithSuggestions,
139
160
  normalizeTag,
140
161
  nowExpr,
141
162
  opContext,
142
163
  permissionLevel,
143
164
  persistSetting,
165
+ primaryRole,
166
+ readIndexedNodes,
167
+ rebuildNodeIndex,
144
168
  removeMember,
145
169
  removeNodeFromTagIndex,
146
170
  renameNest,
@@ -150,6 +174,8 @@ import {
150
174
  resolveNestAccess,
151
175
  resolveNestPath,
152
176
  resolveNestPermission,
177
+ resolveNodeUrl,
178
+ resolveTeamRolesForUser,
153
179
  sendEmailToRecipient,
154
180
  setAllowSelfApprove,
155
181
  setPrimeOnlyReview,
@@ -165,7 +191,7 @@ import {
165
191
  updateMemberRole,
166
192
  validateLicense,
167
193
  verifySmtp
168
- } from "./chunk-SNFGU5Q7.js";
194
+ } from "./chunk-ENDHMQPD.js";
169
195
  import {
170
196
  AppError,
171
197
  ConflictError,
@@ -173,19 +199,22 @@ import {
173
199
  LockedError,
174
200
  NotFoundError,
175
201
  UnauthorizedError,
176
- ValidationError
177
- } from "./chunk-GUNJTORH.js";
202
+ ValidationError,
203
+ VaultBusyError,
204
+ isVaultLockTimeout
205
+ } from "./chunk-YVMSM7LS.js";
178
206
  import {
179
207
  config,
180
208
  getDb,
181
209
  initDb,
182
210
  isEmailListish,
183
211
  isEmailish
184
- } from "./chunk-JMJLNEXE.js";
212
+ } from "./chunk-HYSTFMFG.js";
185
213
  import {
186
214
  ANON_EMAIL,
187
- ANON_USER_ID
188
- } from "./chunk-SLTQACJW.js";
215
+ ANON_USER_ID,
216
+ MAX_FOLDER_DEPTH
217
+ } from "./chunk-YB3LKF7U.js";
189
218
  import {
190
219
  normalizeEmail
191
220
  } from "./chunk-FRQJWGN3.js";
@@ -1079,19 +1108,11 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1079
1108
  );
1080
1109
  const db = getDb();
1081
1110
  const userId = c.get("userId");
1082
- const existing = await db.get(
1083
- "SELECT key_prefix FROM api_keys WHERE user_id = ?",
1084
- [userId]
1085
- );
1086
- if (existing) {
1087
- return c.json(
1088
- {
1089
- error: "An API key already exists for this user. Rotate it instead.",
1090
- key_prefix: existing.key_prefix
1091
- },
1092
- 409
1093
- );
1094
- }
1111
+ const check = await verifyKeyRequest(db, userId, {
1112
+ action: "generate",
1113
+ label: body.label
1114
+ });
1115
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1095
1116
  const apiKey = generateApiKey();
1096
1117
  const keyId = uuid2();
1097
1118
  await db.run(
@@ -1102,11 +1123,11 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1102
1123
  hashApiKey(apiKey),
1103
1124
  getKeyPrefix(apiKey),
1104
1125
  body.nest_id || null,
1105
- body.label || null
1126
+ check.label
1106
1127
  ]
1107
1128
  );
1108
1129
  return c.json(
1109
- { api_key: apiKey, key_prefix: getKeyPrefix(apiKey) },
1130
+ { id: keyId, api_key: apiKey, key_prefix: getKeyPrefix(apiKey), label: check.label },
1110
1131
  201
1111
1132
  );
1112
1133
  });
@@ -1114,15 +1135,22 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1114
1135
  const body = await c.req.json().catch(() => ({}));
1115
1136
  const db = getDb();
1116
1137
  const userId = c.get("userId");
1138
+ const check = await verifyKeyRequest(db, userId, {
1139
+ action: "update",
1140
+ label: body.label,
1141
+ keyId: body.key_id
1142
+ });
1143
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1144
+ const prior = check.key;
1117
1145
  const apiKey = generateApiKey();
1118
1146
  const keyId = uuid2();
1119
- const prior = await db.get(
1120
- "SELECT label, nest_id FROM api_keys WHERE user_id = ?",
1121
- [userId]
1122
- );
1123
- const nestScope = "nest_id" in body ? body.nest_id || null : prior?.nest_id ?? null;
1124
- await db.transaction(async (tx) => {
1125
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [userId]);
1147
+ const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
1148
+ const replaced = await db.transaction(async (tx) => {
1149
+ const removed = await tx.run(
1150
+ "DELETE FROM api_keys WHERE id = ? AND user_id = ?",
1151
+ [prior.id, userId]
1152
+ );
1153
+ if (removed.changes === 0) return false;
1126
1154
  await tx.run(
1127
1155
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
1128
1156
  [
@@ -1131,29 +1159,47 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1131
1159
  hashApiKey(apiKey),
1132
1160
  getKeyPrefix(apiKey),
1133
1161
  nestScope,
1134
- body.label ?? prior?.label ?? null
1162
+ check.label ?? prior.label
1135
1163
  ]
1136
1164
  );
1165
+ return true;
1166
+ });
1167
+ if (!replaced) {
1168
+ return c.json(
1169
+ { error: "That key no longer exists \u2014 reload and try again." },
1170
+ 404
1171
+ );
1172
+ }
1173
+ return c.json({
1174
+ id: keyId,
1175
+ api_key: apiKey,
1176
+ key_prefix: getKeyPrefix(apiKey),
1177
+ label: check.label ?? prior.label
1137
1178
  });
1138
- return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
1139
1179
  });
1140
1180
  authRoutes.get("/keys", authMiddleware, async (c) => {
1141
1181
  const db = getDb();
1182
+ const userId = c.get("userId");
1183
+ const check = await verifyKeyRequest(db, userId, { action: "fetch" });
1184
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1142
1185
  const keys = await db.all(
1143
- "SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ?",
1144
- [c.get("userId")]
1186
+ "SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at ASC, id ASC",
1187
+ [userId]
1145
1188
  );
1146
1189
  return c.json({ keys });
1147
1190
  });
1148
1191
  authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
1149
1192
  const db = getDb();
1150
- const result = await db.run(
1151
- "DELETE FROM api_keys WHERE id = ? AND user_id = ?",
1152
- [c.req.param("keyId"), c.get("userId")]
1153
- );
1154
- if (result.changes === 0) {
1155
- return c.json({ error: "Key not found" }, 404);
1156
- }
1193
+ const userId = c.get("userId");
1194
+ const check = await verifyKeyRequest(db, userId, {
1195
+ action: "update",
1196
+ keyId: c.req.param("keyId")
1197
+ });
1198
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1199
+ await db.run("DELETE FROM api_keys WHERE id = ? AND user_id = ?", [
1200
+ check.key.id,
1201
+ userId
1202
+ ]);
1157
1203
  return c.json({ deleted: true });
1158
1204
  });
1159
1205
  authRoutes.post("/device", async (c) => {
@@ -1610,19 +1656,18 @@ import { Hono as Hono3 } from "hono";
1610
1656
 
1611
1657
  // src/nodes/service.ts
1612
1658
  import {
1613
- parseDocument as parseDocument2,
1659
+ parseDocument,
1614
1660
  normalizeStatus,
1615
1661
  isRejected,
1616
- DocumentNotFoundError
1662
+ filterDocuments,
1663
+ DocumentNotFoundError,
1664
+ NODE_TYPES
1617
1665
  } from "@promptowl/contextnest-engine";
1618
1666
  import { rename, mkdir } from "fs/promises";
1619
- import { dirname, join as join2 } from "path";
1667
+ import { dirname, join } from "path";
1620
1668
 
1621
1669
  // src/governance/safe-publish.ts
1622
- import {
1623
- publishDocument,
1624
- serializeDocument
1625
- } from "@promptowl/contextnest-engine";
1670
+ import { serializeDocument } from "@promptowl/contextnest-engine";
1626
1671
  function serializeNodeSafe(node) {
1627
1672
  return serializeDocument({
1628
1673
  ...node,
@@ -1644,6 +1689,9 @@ function stripUndefinedDeep(value) {
1644
1689
  return value;
1645
1690
  }
1646
1691
 
1692
+ // src/nodes/service.ts
1693
+ import { inputJsonSchema } from "@promptowl/contextnest-engine/api";
1694
+
1647
1695
  // src/governance/prime-service.ts
1648
1696
  async function isPrimeDocument(nestId, tags, metadata) {
1649
1697
  const flag = documentPrimeFlag(metadata);
@@ -1661,313 +1709,6 @@ function documentPrimeFlag(metadata) {
1661
1709
  return void 0;
1662
1710
  }
1663
1711
 
1664
- // src/governance/external-edit-service.ts
1665
- import { readFile } from "fs/promises";
1666
- import { join } from "path";
1667
- import {
1668
- detectDrift,
1669
- stageSuggestion,
1670
- ChainEventLog,
1671
- approveSuggestion,
1672
- rejectSuggestion,
1673
- listSuggestions,
1674
- readSuggestion,
1675
- parseDocument,
1676
- VersionManager,
1677
- computeContentHash,
1678
- getChecksumContent
1679
- } from "@promptowl/contextnest-engine";
1680
- var communityRbac = {
1681
- isCzar: () => false,
1682
- canIngest: () => true,
1683
- isDocOwner: () => true
1684
- };
1685
- function docPath(nestId, documentId) {
1686
- return join(resolveNestPath(nestId), `${documentId}.md`);
1687
- }
1688
- async function readRaw(nestId, documentId) {
1689
- try {
1690
- return await readFile(docPath(nestId, documentId), "utf-8");
1691
- } catch {
1692
- return null;
1693
- }
1694
- }
1695
- async function loadChainHead(storage, documentId) {
1696
- const history = await storage.readHistory(documentId);
1697
- if (!history || history.versions.length === 0) return null;
1698
- const latest = history.versions[history.versions.length - 1];
1699
- try {
1700
- const content = await new VersionManager(storage).reconstructVersion(
1701
- documentId,
1702
- latest.version
1703
- );
1704
- return { version: latest.version, content };
1705
- } catch {
1706
- return null;
1707
- }
1708
- }
1709
- async function loadLatestApprovedNode(nestId, documentId) {
1710
- const { storage } = await engineCache.get(nestId);
1711
- const head = await loadChainHead(storage, documentId);
1712
- if (!head) return null;
1713
- return parseDocument(docPath(nestId, documentId), head.content, documentId);
1714
- }
1715
- async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
1716
- const head = await loadChainHead(storage, documentId);
1717
- if (!head) return false;
1718
- const headBodyHash = computeContentHash(getChecksumContent(head.content));
1719
- return headBodyHash === liveBodyHash;
1720
- }
1721
- async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1722
- const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
1723
- return res?.meta ?? null;
1724
- }
1725
- async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1726
- const { storage } = await engineCache.get(nestId);
1727
- const node = await storage.readDocument(documentId).catch(() => null);
1728
- if (!node) return null;
1729
- const raw = await readRaw(nestId, documentId);
1730
- if (raw == null) return null;
1731
- const drift = detectDrift(raw, node.frontmatter.checksum);
1732
- if (!drift.drifted) return null;
1733
- if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
1734
- return null;
1735
- }
1736
- const approved = await loadChainHead(storage, documentId);
1737
- if (!approved) return null;
1738
- const existing = await listSuggestions(storage, documentId);
1739
- const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
1740
- if (dup) return { meta: dup, created: false };
1741
- const result = await stageSuggestion({
1742
- storage,
1743
- documentId,
1744
- approvedRawContent: approved.content,
1745
- proposedRawContent: raw,
1746
- source: "out-of-band-edit",
1747
- actor,
1748
- docTier: "standard"
1749
- });
1750
- return { meta: result.meta, created: true };
1751
- }
1752
- async function scanNestForDrift(nestId, actor = "system:scanner") {
1753
- const { storage, invalidateDiscovery } = await engineCache.get(nestId);
1754
- invalidateDiscovery();
1755
- const docs = await storage.discoverDocuments();
1756
- const results = await Promise.all(
1757
- docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
1758
- );
1759
- const staged = results.filter((r) => r?.created).length;
1760
- return { scanned: docs.length, staged };
1761
- }
1762
- async function getPendingChange(nestId, documentId) {
1763
- const { storage } = await engineCache.get(nestId);
1764
- const list = await listSuggestions(storage, documentId);
1765
- for (let i = list.length - 1; i >= 0; i--) {
1766
- const meta = list[i];
1767
- if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
1768
- continue;
1769
- }
1770
- return {
1771
- suggestion_id: meta.suggestion_id,
1772
- detected_at: meta.detected_at,
1773
- source: meta.source,
1774
- proposed_hash: meta.proposed_hash
1775
- };
1776
- }
1777
- return null;
1778
- }
1779
- async function listNestExternalEdits(nestId) {
1780
- const { storage } = await engineCache.get(nestId);
1781
- const docs = await storage.discoverDocuments();
1782
- const lists = await Promise.all(
1783
- docs.map(async (doc) => {
1784
- const metas = await listSuggestions(storage, doc.id);
1785
- const fresh = [];
1786
- for (const meta of metas) {
1787
- if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
1788
- continue;
1789
- }
1790
- fresh.push(meta);
1791
- }
1792
- return fresh;
1793
- })
1794
- );
1795
- const entries = lists.flat().map((meta) => ({
1796
- suggestion_id: meta.suggestion_id,
1797
- nest_id: nestId,
1798
- document_id: meta.document_id,
1799
- source: meta.source,
1800
- detected_at: meta.detected_at,
1801
- actor: meta.actor,
1802
- target_hash: meta.target_hash,
1803
- proposed_hash: meta.proposed_hash,
1804
- note: meta.note
1805
- }));
1806
- return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1807
- }
1808
- async function getExternalEditDetail(nestId, documentId, suggestionId) {
1809
- const { storage } = await engineCache.get(nestId);
1810
- const found = await readSuggestion(storage, documentId, suggestionId);
1811
- if (!found) return null;
1812
- return {
1813
- suggestion_id: found.meta.suggestion_id,
1814
- nest_id: nestId,
1815
- document_id: found.meta.document_id,
1816
- source: found.meta.source,
1817
- detected_at: found.meta.detected_at,
1818
- actor: found.meta.actor,
1819
- target_hash: found.meta.target_hash,
1820
- proposed_hash: found.meta.proposed_hash,
1821
- note: found.meta.note,
1822
- patch: found.patch
1823
- };
1824
- }
1825
- async function recordVerdict(nestId, event) {
1826
- try {
1827
- const { storage } = await engineCache.get(nestId);
1828
- await new ChainEventLog(storage).append(event);
1829
- return true;
1830
- } catch (err) {
1831
- console.error(
1832
- `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
1833
- err
1834
- );
1835
- return false;
1836
- }
1837
- }
1838
- async function listExternalEditVerdicts(nestId, documentId) {
1839
- try {
1840
- const { storage } = await engineCache.get(nestId);
1841
- const events = await new ChainEventLog(storage).readByDocument(documentId);
1842
- return events.flatMap((e) => {
1843
- const meta = e.action_metadata || {};
1844
- if (typeof meta.suggestion_id !== "string") return [];
1845
- const reason = meta.rejection_reason;
1846
- const comment = meta.approval_comment;
1847
- return [
1848
- {
1849
- suggestion_id: meta.suggestion_id,
1850
- status: typeof reason === "string" ? "rejected" : "approved",
1851
- actor: e.actor,
1852
- at: e.timestamp,
1853
- note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
1854
- source: typeof meta.source === "string" ? meta.source : void 0
1855
- }
1856
- ];
1857
- });
1858
- } catch (err) {
1859
- console.error(
1860
- `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
1861
- err
1862
- );
1863
- return [];
1864
- }
1865
- }
1866
- async function mirrorVersion(input) {
1867
- const { storage } = await engineCache.get(input.nestId);
1868
- const node = await storage.readDocument(input.documentId);
1869
- const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-EFZGGL3Y.js");
1870
- await upsertVersion2({
1871
- nestId: input.nestId,
1872
- nodeId: input.documentId,
1873
- version: input.version,
1874
- content: node.body || "",
1875
- author: input.actor,
1876
- status: "published",
1877
- tags: node.frontmatter.tags || [],
1878
- changeNote: input.note
1879
- });
1880
- await setApprovedVersion2(
1881
- input.nestId,
1882
- input.documentId,
1883
- input.version,
1884
- input.actor
1885
- );
1886
- }
1887
- async function approveExternalEdit(input) {
1888
- const { storage } = await engineCache.get(input.nestId);
1889
- let result;
1890
- try {
1891
- result = await approveSuggestion({
1892
- storage,
1893
- rbac: communityRbac,
1894
- documentId: input.documentId,
1895
- suggestionId: input.suggestionId,
1896
- actor: input.actor,
1897
- zone: "default",
1898
- comment: input.comment
1899
- });
1900
- } catch (err) {
1901
- console.error(
1902
- `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
1903
- err
1904
- );
1905
- throw err;
1906
- }
1907
- try {
1908
- await mirrorVersion({
1909
- nestId: input.nestId,
1910
- documentId: input.documentId,
1911
- version: result.versionEntry.version,
1912
- actor: input.actor,
1913
- note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
1914
- });
1915
- } catch (err) {
1916
- console.error(
1917
- `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1918
- err
1919
- );
1920
- }
1921
- const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1922
- return { ...result, historyRecorded };
1923
- }
1924
- async function rejectExternalEdit(input) {
1925
- const { storage } = await engineCache.get(input.nestId);
1926
- const result = await rejectSuggestion({
1927
- storage,
1928
- rbac: communityRbac,
1929
- documentId: input.documentId,
1930
- suggestionId: input.suggestionId,
1931
- actor: input.actor,
1932
- zone: "default",
1933
- reason: input.reason
1934
- });
1935
- const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1936
- try {
1937
- const approved = await loadChainHead(storage, input.documentId);
1938
- if (approved) {
1939
- await storage.writeDocument(input.documentId, approved.content);
1940
- }
1941
- } catch (err) {
1942
- console.error(
1943
- `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
1944
- err
1945
- );
1946
- }
1947
- return { ...result, historyRecorded };
1948
- }
1949
- var scannerTimer = null;
1950
- async function scanAllNests() {
1951
- const db = getDb();
1952
- const rows = await db.all("SELECT id FROM nests");
1953
- await Promise.all(
1954
- rows.map(
1955
- ({ id }) => scanNestForDrift(id).catch(
1956
- (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
1957
- )
1958
- )
1959
- );
1960
- }
1961
- function startDriftScanner(intervalMs = 3e4) {
1962
- if (scannerTimer) return;
1963
- scannerTimer = setInterval(() => {
1964
- scanAllNests().catch(
1965
- (err) => console.error("[external-edit] scanner tick failed:", err)
1966
- );
1967
- }, intervalMs);
1968
- scannerTimer.unref?.();
1969
- }
1970
-
1971
1712
  // src/nodes/service.ts
1972
1713
  async function userIdFromEmail(email) {
1973
1714
  const db = getDb();
@@ -1986,17 +1727,42 @@ function assertTitleLength(title) {
1986
1727
  );
1987
1728
  }
1988
1729
  }
1989
- function assertTitleSluggable(title) {
1990
- if (!slugify(title)) {
1730
+ function assertTitleReadable(title) {
1731
+ if (!/[\p{L}\p{N}]/u.test(title)) {
1991
1732
  throw new ValidationError(
1992
- "the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
1733
+ "the title needs at least one letter or number \u2014 it is what search and wiki links read back"
1993
1734
  );
1994
1735
  }
1995
1736
  }
1996
- var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1737
+ var slugify = (s) => s.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1738
+ async function generatedSlug(storage, prefix) {
1739
+ for (let n = 1; n <= 1e3; n++) {
1740
+ const slug = `document-${n}`;
1741
+ try {
1742
+ await storage.readDocument(`${prefix}${slug}`);
1743
+ } catch (err) {
1744
+ if (err instanceof DocumentNotFoundError) return slug;
1745
+ throw err;
1746
+ }
1747
+ }
1748
+ throw new ValidationError(
1749
+ "too many generated document ids in this folder \u2014 give the document a title with Latin letters or digits, or file it elsewhere"
1750
+ );
1751
+ }
1997
1752
  function isIdUnderFolder(id, folder) {
1998
1753
  return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1999
1754
  }
1755
+ function assertSafeFolder(folder) {
1756
+ if (folder.split(/[/\\]/).some((seg) => seg === "..")) {
1757
+ throw new ValidationError(
1758
+ `Invalid folder "${folder}": path traversal ("..") is not allowed.`
1759
+ );
1760
+ }
1761
+ }
1762
+ async function storagePrefix(nestId) {
1763
+ const { storage } = await engineCache.get(nestId);
1764
+ return await storage.detectLayout() === "structured" ? "nodes" : "";
1765
+ }
2000
1766
  function folderToSegments(folder) {
2001
1767
  const segments = folder.split("/").map(slugify).filter(Boolean);
2002
1768
  if (folder.trim() && segments.length === 0) {
@@ -2005,8 +1771,10 @@ function folderToSegments(folder) {
2005
1771
  );
2006
1772
  }
2007
1773
  if (segments[0] === "nodes") segments.shift();
2008
- if (segments.length > 8) {
2009
- throw new ValidationError("folder may nest at most 8 levels deep");
1774
+ if (segments.length > MAX_FOLDER_DEPTH) {
1775
+ throw new ValidationError(
1776
+ `folder may nest at most ${MAX_FOLDER_DEPTH} levels deep`
1777
+ );
2010
1778
  }
2011
1779
  if (segments.some((seg) => seg.length > 100)) {
2012
1780
  throw new ValidationError(
@@ -2037,12 +1805,16 @@ function toSafeError(err, context, message) {
2037
1805
  "This document is rejected. Change its status before saving."
2038
1806
  );
2039
1807
  }
1808
+ if (isVaultLockTimeout(err)) {
1809
+ console.error(`${context}:`, err);
1810
+ return new VaultBusyError();
1811
+ }
2040
1812
  console.error(`${context}:`, err);
2041
1813
  return new AppError(500, message);
2042
1814
  }
2043
1815
  function bodyOnly(nodeId, raw) {
2044
1816
  try {
2045
- return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
1817
+ return parseDocument(`${nodeId}.md`, raw, nodeId).body ?? "";
2046
1818
  } catch {
2047
1819
  return raw;
2048
1820
  }
@@ -2051,6 +1823,12 @@ var RUNNABLE_NODE_TYPES = ["agent", "skill"];
2051
1823
  function isRunnableType(type) {
2052
1824
  return RUNNABLE_NODE_TYPES.includes(type || "");
2053
1825
  }
1826
+ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
1827
+ ...NODE_TYPES,
1828
+ ...RUNNABLE_NODE_TYPES,
1829
+ "artifact",
1830
+ "table"
1831
+ ]);
2054
1832
  function toNodeResponse(node) {
2055
1833
  const fm = node.frontmatter;
2056
1834
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -2127,6 +1905,15 @@ async function resolveAuthorship(nestId, nodes) {
2127
1905
  }
2128
1906
  return result;
2129
1907
  }
1908
+ var folderScopeSupported = null;
1909
+ function engineScopesFolders() {
1910
+ if (folderScopeSupported === null) {
1911
+ const op = engineApi.getOperation("context_list");
1912
+ const properties = op ? inputJsonSchema(op).properties ?? {} : {};
1913
+ folderScopeSupported = "folder" in properties;
1914
+ }
1915
+ return folderScopeSupported;
1916
+ }
2130
1917
  async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2131
1918
  if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
2132
1919
  throw new ForbiddenError(
@@ -2168,29 +1955,52 @@ async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {})
2168
1955
  };
2169
1956
  }
2170
1957
  async function listNodesForCaller(nestId, userId, filters = {}) {
1958
+ if (filters.folder !== void 0) assertSafeFolder(filters.folder);
2171
1959
  const { versions: versionManager } = await engineCache.get(nestId);
2172
1960
  const userEmail = await resolveCallerEmail(userId);
2173
- const { documents } = await engineApi.run(
2174
- "context_list",
2175
- {
2176
- ...filters.type ? { type: filters.type } : {},
2177
- ...filters.tag ? { tag: filters.tag } : {},
2178
- ...filters.status ? { status: filters.status } : {},
2179
- // Unlike the CLI and mcp-server, a retired node stays in this listing:
2180
- // here it is an unpublished document its stewards may still need to act
2181
- // on, not one removed from retrieval. An imported vault carrying
2182
- // `status: rejected` registers exactly that way.
2183
- include_retired: true,
2184
- full: true
2185
- },
2186
- await opContext(nestId, userEmail)
2187
- );
2188
- const nodes = documents.map((d) => ({
1961
+ const listFilters = {
1962
+ ...filters.type ? { type: filters.type } : {},
1963
+ ...filters.tag ? { tag: filters.tag } : {},
1964
+ ...filters.status ? { status: filters.status } : {}
1965
+ };
1966
+ const frontmatterFilters = { ...listFilters, includeRetired: true };
1967
+ let documents;
1968
+ if (filters.withContent) {
1969
+ const scopeInEngine = filters.folder !== void 0 && engineScopesFolders();
1970
+ const scopePrefix = scopeInEngine ? await storagePrefix(nestId) : "";
1971
+ ({ documents } = await engineApi.run(
1972
+ "context_list",
1973
+ {
1974
+ ...listFilters,
1975
+ include_retired: true,
1976
+ ...scopeInEngine ? {
1977
+ folder: [scopePrefix, filters.folder].filter(Boolean).join("/"),
1978
+ recursive: false
1979
+ } : {},
1980
+ full: true
1981
+ },
1982
+ await opContext(nestId, userEmail)
1983
+ ));
1984
+ } else {
1985
+ await ensureNodeIndex(nestId);
1986
+ const indexed = await readIndexedNodes(
1987
+ nestId,
1988
+ filters.folder !== void 0 ? { folder: filters.folder } : {}
1989
+ );
1990
+ documents = filterDocuments(indexed, {
1991
+ ...frontmatterFilters,
1992
+ includeRetired: true
1993
+ });
1994
+ }
1995
+ const scoped = filters.folder === void 0 ? documents : documents.filter((d) => folderOfId(d.id) === filters.folder);
1996
+ const nodes = scoped.map((d) => ({
2189
1997
  id: d.id,
2190
1998
  filePath: "",
2191
1999
  rawContent: "",
2192
2000
  frontmatter: d.frontmatter,
2193
- body: d.body
2001
+ // Empty on the indexed path: no listing renders a body, and the
2002
+ // approved-only branch below serves its own from the version store.
2003
+ body: d.body ?? ""
2194
2004
  }));
2195
2005
  const publicReader = await isPublicReader(nestId, userId);
2196
2006
  const approvedOnly = publicReader || filters.approvedOnly === true;
@@ -2207,6 +2017,13 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2207
2017
  accessible = accessible.filter((d) => approvedVersions.has(d.id));
2208
2018
  }
2209
2019
  if (filters.limit) accessible = accessible.slice(0, filters.limit);
2020
+ const [displayStatuses, driftedDocs] = approvedVersions ? [null, null] : await Promise.all([
2021
+ getDisplayStatuses(nestId),
2022
+ // From the DB, not a vault walk. The disk truth behind it is
2023
+ // reconciled by the index rebuild and kept current by the stage /
2024
+ // approve / reject paths.
2025
+ nodesWithSuggestions(nestId)
2026
+ ]);
2210
2027
  const authorship = publicReader ? null : await resolveAuthorship(
2211
2028
  nestId,
2212
2029
  accessible.map((d) => ({
@@ -2249,22 +2066,125 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2249
2066
  return r;
2250
2067
  }
2251
2068
  let pending = null;
2252
- try {
2253
- pending = await getPendingChange(nestId, doc.id);
2254
- } catch {
2255
- pending = null;
2069
+ if (driftedDocs?.has(doc.id)) {
2070
+ try {
2071
+ pending = await getPendingChange(nestId, doc.id);
2072
+ } catch {
2073
+ pending = null;
2074
+ }
2256
2075
  }
2257
2076
  if (pending) {
2258
2077
  r.pendingChange = pending;
2259
2078
  r.status = "external_edit_pending";
2260
2079
  } else {
2261
- r.status = await getDisplayStatus(nestId, r.id);
2080
+ r.status = displayStatuses?.get(r.id) ?? "draft";
2262
2081
  }
2263
2082
  return r;
2264
2083
  })
2265
2084
  );
2266
2085
  return enriched;
2267
2086
  }
2087
+ function withSubtreeTotals(direct) {
2088
+ return direct.map(({ path, count }) => ({
2089
+ path,
2090
+ count,
2091
+ total: direct.reduce(
2092
+ (sum, f) => f.path === path || f.path.startsWith(`${path}/`) ? sum + f.count : sum,
2093
+ 0
2094
+ )
2095
+ })).sort((a, b) => a.path.localeCompare(b.path));
2096
+ }
2097
+ async function listNestFolders(nestId, opts = {}) {
2098
+ if (opts.folder !== void 0) assertSafeFolder(opts.folder);
2099
+ await ensureNodeIndex(nestId);
2100
+ const counts = /* @__PURE__ */ new Map();
2101
+ for (const { path, count } of await indexedFolderCounts(nestId)) {
2102
+ counts.set(path, count);
2103
+ let parent = path;
2104
+ while (parent.includes("/")) {
2105
+ parent = parent.slice(0, parent.lastIndexOf("/"));
2106
+ if (!counts.has(parent)) counts.set(parent, 0);
2107
+ }
2108
+ }
2109
+ const direct = [...counts].map(([path, count]) => ({ path, count }));
2110
+ const scope = opts.folder ?? "";
2111
+ const inScope = direct.filter((f) => {
2112
+ if (scope && !f.path.startsWith(`${scope}/`)) return false;
2113
+ if (opts.recursive === false) {
2114
+ const rel = scope ? f.path.slice(scope.length + 1) : f.path;
2115
+ return !rel.includes("/");
2116
+ }
2117
+ return true;
2118
+ });
2119
+ return withSubtreeTotals(inScope);
2120
+ }
2121
+ var RECENT_LIMIT = 3;
2122
+ async function nestCensus(nestId, userId) {
2123
+ const userEmail = await resolveCallerEmail(userId);
2124
+ await ensureNodeIndex(nestId);
2125
+ const documents = await readIndexedNodes(nestId);
2126
+ const visible = await filterAccessible(nestId, userId, userEmail, documents);
2127
+ const tags = {};
2128
+ const types = {};
2129
+ const direct = /* @__PURE__ */ new Map();
2130
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1e3;
2131
+ let editedLast7d = 0;
2132
+ for (const d of visible) {
2133
+ const fm = d.frontmatter;
2134
+ types[fm.type || "document"] = (types[fm.type || "document"] || 0) + 1;
2135
+ for (const t of fm.tags || []) tags[String(t)] = (tags[String(t)] || 0) + 1;
2136
+ const folder = folderOfId(d.id);
2137
+ if (folder) direct.set(folder, (direct.get(folder) || 0) + 1);
2138
+ const edited = Date.parse(String(fm.updated_at || ""));
2139
+ if (!Number.isNaN(edited) && edited >= cutoff) editedLast7d++;
2140
+ }
2141
+ const paths = /* @__PURE__ */ new Set();
2142
+ for (const folder of direct.keys()) {
2143
+ const segs = folder.split("/");
2144
+ for (let i = 1; i <= segs.length; i++) paths.add(segs.slice(0, i).join("/"));
2145
+ }
2146
+ const folders = withSubtreeTotals(
2147
+ [...paths].map((path) => ({ path, count: direct.get(path) ?? 0 }))
2148
+ );
2149
+ const statusMap = await getDisplayStatuses(nestId);
2150
+ const statuses = {};
2151
+ for (const d of visible) {
2152
+ const st = statusMap.get(d.id) ?? "draft";
2153
+ statuses[st] = (statuses[st] || 0) + 1;
2154
+ }
2155
+ const authorship = await resolveAuthorship(
2156
+ nestId,
2157
+ visible.map((d) => ({
2158
+ id: d.id,
2159
+ frontmatterAuthor: d.frontmatter.author ?? null
2160
+ }))
2161
+ );
2162
+ const authors = [
2163
+ ...new Set(
2164
+ [...authorship.values()].map((a) => a.author).filter((a) => !!a)
2165
+ )
2166
+ ].sort((a, b) => a.localeCompare(b));
2167
+ const recent = visible.filter((d) => d.frontmatter.updated_at).sort(
2168
+ (a, b) => String(b.frontmatter.updated_at).localeCompare(
2169
+ String(a.frontmatter.updated_at)
2170
+ )
2171
+ ).slice(0, RECENT_LIMIT).map((d) => ({
2172
+ id: d.id,
2173
+ title: String(d.frontmatter.title ?? ""),
2174
+ type: d.frontmatter.type || "document",
2175
+ updated_at: d.frontmatter.updated_at
2176
+ }));
2177
+ return {
2178
+ total: visible.length,
2179
+ folders,
2180
+ tags,
2181
+ types,
2182
+ statuses,
2183
+ authors,
2184
+ editedLast7d,
2185
+ recent
2186
+ };
2187
+ }
2268
2188
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2269
2189
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
2270
2190
  }
@@ -2290,14 +2210,27 @@ async function autoPublishReason(nestId, userEmail, doc) {
2290
2210
  return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2291
2211
  }
2292
2212
  async function createNode(nestId, input, userEmail) {
2213
+ const generatedId = !input.id && !slugify(input.title);
2214
+ for (let attempt = 0; ; attempt++) {
2215
+ try {
2216
+ return await createNodeOnce(nestId, input, userEmail);
2217
+ } catch (err) {
2218
+ if (!generatedId || attempt >= 3 || !(err instanceof ConflictError)) {
2219
+ throw err;
2220
+ }
2221
+ }
2222
+ }
2223
+ }
2224
+ async function createNodeOnce(nestId, input, userEmail) {
2293
2225
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2294
2226
  assertTitleLength(input.title);
2295
- if (!input.id) assertTitleSluggable(input.title);
2227
+ assertTitleReadable(input.title);
2296
2228
  const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2297
2229
  let id = input.id;
2298
2230
  if (!id) {
2299
2231
  const folderSegments = folderToSegments(input.folder ?? "");
2300
- id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2232
+ const prefix = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/` : "nodes/";
2233
+ id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
2301
2234
  }
2302
2235
  const tags = (input.tags || []).map(normalizeTag2);
2303
2236
  const requestedType = input.type || "document";
@@ -2307,6 +2240,11 @@ async function createNode(nestId, input, userEmail) {
2307
2240
  if (requestedType === "table" && !config.TYPE_TABLE_ENABLED) {
2308
2241
  throw new ValidationError("Table nodes are disabled on this server");
2309
2242
  }
2243
+ if (!ACCEPTED_NODE_TYPES.has(requestedType)) {
2244
+ throw new ValidationError(
2245
+ `unknown node type "${requestedType}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
2246
+ );
2247
+ }
2310
2248
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
2311
2249
  throw new ValidationError(
2312
2250
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -2558,7 +2496,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2558
2496
  if (patch.title) {
2559
2497
  if (patch.title !== node.frontmatter.title) {
2560
2498
  assertTitleLength(patch.title);
2561
- assertTitleSluggable(patch.title);
2499
+ assertTitleReadable(patch.title);
2562
2500
  }
2563
2501
  node = {
2564
2502
  ...node,
@@ -2706,6 +2644,8 @@ var NODE_KEYED_TABLES = [
2706
2644
  ["annotation_threads", "node_id"],
2707
2645
  ["comments", "node_id"],
2708
2646
  ["node_tag_index", "node_id"],
2647
+ ["node_index", "node_id"],
2648
+ ["node_suggestions", "node_id"],
2709
2649
  ["watchers", "node_id"],
2710
2650
  ["schedules", "agent_node"],
2711
2651
  // A live capability token bound to one agent node — deleting the agent must
@@ -2784,8 +2724,8 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2784
2724
  await storage.writeDocument(newId, serializeNodeSafe(moved));
2785
2725
  try {
2786
2726
  const nestPath = resolveNestPath(nestId);
2787
- const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2788
- const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2727
+ const oldChain = join(nestPath, dirname(nodeId), ".versions", leaf);
2728
+ const newChain = join(nestPath, dirname(newId), ".versions", leaf);
2789
2729
  if (oldChain !== newChain) {
2790
2730
  await mkdir(dirname(newChain), { recursive: true });
2791
2731
  await rename(oldChain, newChain);
@@ -2818,6 +2758,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2818
2758
  await storage.deleteDocument(oldDerived);
2819
2759
  } catch {
2820
2760
  }
2761
+ try {
2762
+ await storage.regenerateIndex();
2763
+ } catch (err) {
2764
+ console.error("[move] regenerateIndex failed", nestId, nodeId, newId, err);
2765
+ }
2821
2766
  await trackEvent("node.move", { nestId, nodeId, newId });
2822
2767
  return { node: moved, previousId: nodeId };
2823
2768
  });
@@ -2888,7 +2833,7 @@ async function deleteFolder(nestId, folder, userEmail) {
2888
2833
 
2889
2834
  // src/nests/unsynced-service.ts
2890
2835
  import { readdirSync, readFileSync, statSync } from "fs";
2891
- import { join as join3, relative, resolve } from "path";
2836
+ import { join as join2, relative, resolve } from "path";
2892
2837
  import { rm } from "fs/promises";
2893
2838
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2894
2839
  function isNestStorageRoot(absPath) {
@@ -2905,7 +2850,7 @@ function scanMarkdown(dir) {
2905
2850
  }
2906
2851
  for (const e of entries) {
2907
2852
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
2908
- const full = join3(dir, e.name);
2853
+ const full = join2(dir, e.name);
2909
2854
  if (e.isDirectory()) {
2910
2855
  const sub = scanMarkdown(full);
2911
2856
  count += sub.count;
@@ -2955,7 +2900,7 @@ function collectLeafFolders(rel, abs) {
2955
2900
  if (!e.isDirectory()) continue;
2956
2901
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
2957
2902
  out.push(
2958
- ...collectLeafFolders(`${rel}/${e.name}`, join3(abs, e.name))
2903
+ ...collectLeafFolders(`${rel}/${e.name}`, join2(abs, e.name))
2959
2904
  );
2960
2905
  }
2961
2906
  return out;
@@ -2973,7 +2918,7 @@ function listUnsyncedFolders() {
2973
2918
  if (!e.isDirectory()) continue;
2974
2919
  if (e.name.startsWith(".")) continue;
2975
2920
  if (RESERVED.has(e.name)) continue;
2976
- const abs = join3(root, e.name);
2921
+ const abs = join2(root, e.name);
2977
2922
  if (isNestStorageRoot(abs)) continue;
2978
2923
  out.push(...collectLeafFolders(e.name, abs));
2979
2924
  }
@@ -3010,7 +2955,7 @@ function collectMarkdownFiles(dir, root) {
3010
2955
  }
3011
2956
  for (const e of entries) {
3012
2957
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
3013
- const full = join3(dir, e.name);
2958
+ const full = join2(dir, e.name);
3014
2959
  if (e.isDirectory()) {
3015
2960
  out.push(...collectMarkdownFiles(full, root));
3016
2961
  } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
@@ -3028,7 +2973,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3028
2973
  `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
3029
2974
  );
3030
2975
  assertSafeFolderName(folderName);
3031
- const src = join3(config.DATA_ROOT, folderName);
2976
+ const src = join2(config.DATA_ROOT, folderName);
3032
2977
  if (isNestStorageRoot(src)) {
3033
2978
  throw new ValidationError("Folder is not eligible for sync");
3034
2979
  }
@@ -3084,7 +3029,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3084
3029
  async function deleteUnsyncedFolder(folderName) {
3085
3030
  console.log(`[unsynced] delete requested folder="${folderName}"`);
3086
3031
  assertSafeFolderName(folderName);
3087
- const src = join3(config.DATA_ROOT, folderName);
3032
+ const src = join2(config.DATA_ROOT, folderName);
3088
3033
  if (isNestStorageRoot(src)) {
3089
3034
  throw new ValidationError("Folder is not eligible for delete");
3090
3035
  }
@@ -3180,9 +3125,11 @@ nestRoutes.get("/", async (c) => {
3180
3125
  }
3181
3126
  const out = await Promise.all(deduped.map(annotate));
3182
3127
  const docRows = await db.all(
3183
- "SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
3128
+ `SELECT nest_id, COUNT(DISTINCT node_id) AS c, MAX(created_at) AS last_activity
3129
+ FROM node_versions GROUP BY nest_id`
3184
3130
  );
3185
3131
  const docByNest = new Map(docRows.map((r) => [r.nest_id, Number(r.c)]));
3132
+ const activityByNest = new Map(docRows.map((r) => [r.nest_id, r.last_activity]));
3186
3133
  const collabRows = await db.all(
3187
3134
  "SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
3188
3135
  );
@@ -3236,6 +3183,7 @@ nestRoutes.get("/", async (c) => {
3236
3183
  collaborator_emails: []
3237
3184
  } : {
3238
3185
  ...n,
3186
+ last_activity: activityByNest.get(n.id) ?? null,
3239
3187
  document_count: docByNest.get(n.id) ?? 0,
3240
3188
  collaborator_count: collabByNest.get(n.id) ?? 0,
3241
3189
  steward_count: stewardByNest.get(n.id) ?? 0,
@@ -3499,7 +3447,7 @@ async function addCollaborator(params) {
3499
3447
  if (existing) {
3500
3448
  userId = existing.id;
3501
3449
  } else {
3502
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
3450
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3503
3451
  userId = uuid3();
3504
3452
  await db.run(
3505
3453
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
@@ -3691,7 +3639,7 @@ async function resolveGrantee(email) {
3691
3639
  const e = normalizeEmail(email);
3692
3640
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
3693
3641
  if (existing) return existing.id;
3694
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
3642
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3695
3643
  const id = uuid4();
3696
3644
  await db.run(
3697
3645
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
@@ -3699,6 +3647,41 @@ async function resolveGrantee(email) {
3699
3647
  );
3700
3648
  return id;
3701
3649
  }
3650
+ async function assertGrantAddsAccess(params) {
3651
+ const { nestId, email, userId, targetType, target, role } = params;
3652
+ const roles = [];
3653
+ if (userId) {
3654
+ const access = await resolveNestAccess(nestId, userId);
3655
+ if (access.ownerId === userId) roles.push("owner");
3656
+ const collab = collabPermToRole(access.collaboratorPermission);
3657
+ if (collab) roles.push(collab);
3658
+ for (const teamRole of await resolveTeamRolesForUser(nestId, userId)) {
3659
+ roles.push(teamRole);
3660
+ }
3661
+ }
3662
+ if (targetType === "document") {
3663
+ const stewards = await resolveStewardsForNode(nestId, target);
3664
+ for (const s of stewards) {
3665
+ if (s.steward.userEmail.toLowerCase() === email.toLowerCase()) {
3666
+ roles.push(s.steward.role);
3667
+ }
3668
+ }
3669
+ }
3670
+ const covered = role === "write" ? canEditWith(roles) : roles.length > 0;
3671
+ if (covered) {
3672
+ const held = primaryRole(roles) ?? "existing";
3673
+ throw new ValidationError(
3674
+ `${email} already has ${held} access here, so this ${targetType} is already ${role === "write" ? "editable by" : "visible to"} them.`
3675
+ );
3676
+ }
3677
+ if (!userId) return;
3678
+ const existing = await resolveNodeGrant(nestId, userId, target);
3679
+ if (existing && (role === "read" || existing === "write")) {
3680
+ throw new ValidationError(
3681
+ `${email} has already been shared this ${targetType} (${existing === "write" ? "can edit" : "can view"}).`
3682
+ );
3683
+ }
3684
+ }
3702
3685
  grantRoutes.post("/", async (c) => {
3703
3686
  const nestId = c.req.param("nestId");
3704
3687
  await requireManage(c, nestId);
@@ -3714,7 +3697,19 @@ grantRoutes.post("/", async (c) => {
3714
3697
  if (!["read", "write"].includes(role)) {
3715
3698
  throw new ValidationError("role must be read | write");
3716
3699
  }
3717
- const userId = await resolveGrantee(body.email);
3700
+ const email = normalizeEmail(body.email);
3701
+ const known = await getDb().get("SELECT id FROM users WHERE LOWER(email) = ?", [
3702
+ email
3703
+ ]);
3704
+ await assertGrantAddsAccess({
3705
+ nestId,
3706
+ email,
3707
+ userId: known?.id ?? null,
3708
+ targetType,
3709
+ target,
3710
+ role
3711
+ });
3712
+ const userId = known?.id ?? await resolveGrantee(body.email);
3718
3713
  const grant = await createGrant({
3719
3714
  nestId,
3720
3715
  targetType,
@@ -4122,7 +4117,7 @@ function toSearchHit(node, query) {
4122
4117
  import { Hono as Hono8 } from "hono";
4123
4118
 
4124
4119
  // src/nodes/markdown-export.ts
4125
- import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
4120
+ import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
4126
4121
  function nodeToMarkdown(node) {
4127
4122
  const tags = node.tags ?? [];
4128
4123
  return [
@@ -4158,7 +4153,7 @@ function rawContentType(nodeType) {
4158
4153
  function frontmatterBlockOf(raw) {
4159
4154
  if (!raw || !raw.startsWith("---")) return "";
4160
4155
  try {
4161
- const body = parseDocument3("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4156
+ const body = parseDocument2("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4162
4157
  if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
4163
4158
  } catch {
4164
4159
  }
@@ -4204,7 +4199,14 @@ nodeRoutes.get("/", async (c) => {
4204
4199
  // Lets an owner preview what making the nest public would expose.
4205
4200
  ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4206
4201
  // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4207
- ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
4202
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {},
4203
+ // Present (even as "") = one folder level only, for the lazily-expanded
4204
+ // document tree. Absent = the whole nest, as every existing caller expects.
4205
+ ...q.folder !== void 0 ? { folder: q.folder } : {},
4206
+ // Bodies are off by default — the list is served from the DB index, which
4207
+ // holds metadata only. Opt back in and the listing crawls the vault for
4208
+ // them; kept for any client that was reading `content` off this route.
4209
+ ...q.content === "1" || q.content === "true" ? { withContent: true } : {}
4208
4210
  });
4209
4211
  return c.json({ count: nodes.length, nodes });
4210
4212
  });
@@ -4246,7 +4248,7 @@ nodeRoutes.post("/", async (c) => {
4246
4248
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4247
4249
  const nestId = c.req.param("nestId");
4248
4250
  const nodeId = c.req.param("nodeId");
4249
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-OHTTZFND.js");
4251
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-JAFSU5GN.js");
4250
4252
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4251
4253
  nestId,
4252
4254
  nodeId
@@ -4267,7 +4269,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4267
4269
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4268
4270
  const nestId = c.req.param("nestId");
4269
4271
  const nodeId = c.req.param("nodeId");
4270
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3CCKDO5N.js");
4272
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-LK4M2C43.js");
4271
4273
  const history = await getReviewHistory2(nestId, nodeId);
4272
4274
  return c.json({ reviews: history });
4273
4275
  });
@@ -4906,7 +4908,6 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
4906
4908
 
4907
4909
  // src/nodes/query-routes.ts
4908
4910
  import { Hono as Hono10 } from "hono";
4909
- import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
4910
4911
 
4911
4912
  // src/tables/service.ts
4912
4913
  function splitCsvLine(line) {
@@ -5327,7 +5328,7 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
5327
5328
 
5328
5329
  // src/nests/bundle-service.ts
5329
5330
  import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
5330
- import { join as join4, sep } from "path";
5331
+ import { join as join3, sep } from "path";
5331
5332
  import JSZip from "jszip";
5332
5333
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
5333
5334
  function walkFiles(root) {
@@ -5340,7 +5341,7 @@ function walkFiles(root) {
5340
5341
  }
5341
5342
  for (const rel of entries) {
5342
5343
  try {
5343
- if (statSync2(join4(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
5344
+ if (statSync2(join3(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
5344
5345
  } catch {
5345
5346
  }
5346
5347
  }
@@ -5381,7 +5382,7 @@ async function exportNestBundle(nestId, includeDrafts) {
5381
5382
  const segs = rel.split("/");
5382
5383
  const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
5383
5384
  if (!keep) continue;
5384
- zip.file(rel, readFileSync2(join4(nestPath, rel)));
5385
+ zip.file(rel, readFileSync2(join3(nestPath, rel)));
5385
5386
  }
5386
5387
  trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
5387
5388
  return zip.generateAsync({ type: "arraybuffer" });
@@ -5552,7 +5553,9 @@ queryRoutes.get("/changes", async (c) => {
5552
5553
  const includeContent = ["1", "true"].includes(
5553
5554
  c.req.query("include_content") || ""
5554
5555
  );
5555
- const all = await listNodesForCaller(nestId, userId);
5556
+ const all = await listNodesForCaller(nestId, userId, {
5557
+ ...includeContent ? { withContent: true } : {}
5558
+ });
5556
5559
  const db = getDb();
5557
5560
  const publicReader = await isPublicReader(nestId, userId);
5558
5561
  const approvedRows = await db.all(
@@ -5619,7 +5622,10 @@ queryRoutes.get("/runnables", async (c) => {
5619
5622
  const nestId = c.req.param("nestId");
5620
5623
  const userId = c.get("userId");
5621
5624
  const all = await listNodesForCaller(nestId, userId, {
5622
- type: RUNNABLE_NODE_TYPES
5625
+ type: RUNNABLE_NODE_TYPES,
5626
+ // The prompt/skill body IS the payload here — an orchestrator pulls it to
5627
+ // run the thing, so this surface pays for the file reads.
5628
+ withContent: true
5623
5629
  });
5624
5630
  const runnables = all.map((n) => ({
5625
5631
  id: n.id,
@@ -5661,11 +5667,15 @@ queryRoutes.get("/graph", async (c) => {
5661
5667
  function approxTokens(text) {
5662
5668
  return Math.ceil(text.length / 4);
5663
5669
  }
5664
- function formatContextBlock(node) {
5670
+ function formatContextBlock(node, url) {
5665
5671
  const tags = (node.frontmatter.tags || []).join(" ");
5666
- const header = tags ? `# ${node.frontmatter.title}
5672
+ const meta = [
5673
+ tags ? `_tags: ${tags}_` : null,
5674
+ url ? `_source: ${url}_` : null
5675
+ ].filter(Boolean);
5676
+ const header = meta.length ? `# ${node.frontmatter.title}
5667
5677
 
5668
- _tags: ${tags}_
5678
+ ${meta.join("\n")}
5669
5679
 
5670
5680
  ` : `# ${node.frontmatter.title}
5671
5681
 
@@ -5678,6 +5688,7 @@ queryRoutes.post("/context", async (c) => {
5678
5688
  throw new ValidationError("prompt or selector is required");
5679
5689
  }
5680
5690
  const nestId = c.req.param("nestId");
5691
+ const baseUrl = requestBaseUrl(c.req.url);
5681
5692
  const { query: queryEngine, storage } = await engineCache.get(nestId);
5682
5693
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
5683
5694
  const hops = body.hops ?? 2;
@@ -5786,20 +5797,21 @@ queryRoutes.post("/context", async (c) => {
5786
5797
  const readable = await Promise.all(
5787
5798
  accessible.map(async (doc) => ({
5788
5799
  ...doc,
5789
- body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
5800
+ body: await resolveReadableBody(nestId, doc.id, userId, doc.body || ""),
5801
+ url: resolveNodeUrl(doc, { nestId, baseUrl })
5790
5802
  }))
5791
5803
  );
5792
5804
  const included = [];
5793
5805
  let tokenCount = 0;
5794
5806
  for (const doc of readable) {
5795
- const block = formatContextBlock(doc);
5807
+ const block = formatContextBlock(doc, doc.url);
5796
5808
  const blockTokens = approxTokens(block);
5797
5809
  if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
5798
5810
  included.push(doc);
5799
5811
  tokenCount += blockTokens;
5800
5812
  }
5801
5813
  const truncatedByBudget = accessible.length - included.length;
5802
- const contextMarkdown = included.map(formatContextBlock).join("\n\n---\n\n");
5814
+ const contextMarkdown = included.map((n) => formatContextBlock(n, n.url)).join("\n\n---\n\n");
5803
5815
  trackEvent("context.resolve", {
5804
5816
  nestId,
5805
5817
  selector_from_prompt: !!compileDetail,
@@ -5812,6 +5824,7 @@ queryRoutes.post("/context", async (c) => {
5812
5824
  nodes: included.map((n) => ({
5813
5825
  id: n.id,
5814
5826
  title: n.frontmatter.title,
5827
+ url: n.url,
5815
5828
  tags: n.frontmatter.tags || [],
5816
5829
  type: n.frontmatter.type || "document"
5817
5830
  })),
@@ -5914,6 +5927,25 @@ queryRoutes.get("/overview", async (c) => {
5914
5927
  }))
5915
5928
  });
5916
5929
  });
5930
+ queryRoutes.get("/census", async (c) => {
5931
+ const census = await nestCensus(c.req.param("nestId"), c.get("userId"));
5932
+ return c.json({ census });
5933
+ });
5934
+ queryRoutes.get("/folders", async (c) => {
5935
+ const q = c.req.query();
5936
+ const folders = await listNestFolders(c.req.param("nestId"), {
5937
+ ...q.folder !== void 0 ? { folder: q.folder } : {},
5938
+ ...q.recursive === "0" || q.recursive === "false" ? { recursive: false } : {}
5939
+ });
5940
+ return c.json({ folders });
5941
+ });
5942
+ queryRoutes.post("/reindex", async (c) => {
5943
+ if (permissionLevel(c.get("nestPermission")) < permissionLevel("write")) {
5944
+ throw new ForbiddenError("Reindexing a nest requires write access");
5945
+ }
5946
+ const indexed = await rebuildNodeIndex(c.req.param("nestId"));
5947
+ return c.json({ indexed });
5948
+ });
5917
5949
  queryRoutes.get("/comment-counts", async (c) => {
5918
5950
  const counts = await countThreadsByNode(c.req.param("nestId"));
5919
5951
  return c.json({ counts });
@@ -6006,48 +6038,51 @@ queryRoutes.post("/publish", async (c) => {
6006
6038
  }
6007
6039
  }
6008
6040
  const nestId = c.req.param("nestId");
6009
- const { storage } = await engineCache.get(nestId);
6041
+ const userEmail = await resolveCallerEmail(c.get("userId"));
6010
6042
  const created = [];
6043
+ const skipped = [];
6044
+ const failed = [];
6011
6045
  if (body.context_md) {
6046
+ const { storage } = await engineCache.get(nestId);
6012
6047
  await storage.writeContextMd(body.context_md);
6048
+ await storage.regenerateIndex();
6013
6049
  }
6014
6050
  for (const doc of body.documents || []) {
6015
6051
  if (!doc.title || !doc.content) continue;
6016
- const slug = doc.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
6017
- const id = `nodes/${slug}`;
6018
- const now = (/* @__PURE__ */ new Date()).toISOString();
6019
- const tags = doc.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
6020
- const node = {
6021
- id,
6022
- filePath: "",
6023
- frontmatter: {
6052
+ try {
6053
+ const result = await createNode(
6054
+ nestId,
6055
+ {
6056
+ title: doc.title,
6057
+ content: doc.content,
6058
+ type: doc.type,
6059
+ tags: doc.tags,
6060
+ scope: doc.scope
6061
+ },
6062
+ userEmail
6063
+ );
6064
+ created.push(result.node.id);
6065
+ } catch (err) {
6066
+ if (err instanceof ConflictError) {
6067
+ skipped.push(doc.title);
6068
+ continue;
6069
+ }
6070
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) throw err;
6071
+ if (!(err instanceof AppError)) console.error("bulk publish row failed", err);
6072
+ failed.push({
6024
6073
  title: doc.title,
6025
- type: doc.type || "document",
6026
- tags,
6027
- status: "draft",
6028
- version: 1,
6029
- created_at: now,
6030
- updated_at: now,
6031
- metadata: {
6032
- owners: ["*"],
6033
- scope: doc.scope || "team"
6034
- }
6035
- },
6036
- body: doc.content,
6037
- rawContent: ""
6038
- };
6039
- const serialized = serializeDocument3(node);
6040
- await storage.writeDocument(id, serialized);
6041
- await syncNodeTags(nestId, id, tags);
6042
- created.push(id);
6074
+ error: err instanceof AppError ? err.message : "Could not create this document"
6075
+ });
6076
+ }
6043
6077
  }
6044
- if (created.length > 0 || body.context_md) await storage.regenerateIndex();
6045
6078
  trackEvent("nest.publish", { nestId, count: created.length });
6046
6079
  return c.json(
6047
6080
  {
6048
6081
  published: created.length,
6049
6082
  context_md_updated: !!body.context_md,
6050
- node_ids: created
6083
+ node_ids: created,
6084
+ skipped,
6085
+ failed
6051
6086
  },
6052
6087
  201
6053
6088
  );
@@ -6058,30 +6093,6 @@ import { Hono as Hono12 } from "hono";
6058
6093
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6059
6094
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6060
6095
 
6061
- // src/shared/node-id.ts
6062
- function assertSafeNodeId(rawId) {
6063
- let id = rawId;
6064
- try {
6065
- id = decodeURIComponent(rawId);
6066
- } catch {
6067
- }
6068
- if (!id || id.length > 512) {
6069
- throw new ValidationError("invalid node id");
6070
- }
6071
- if (id.includes("\0") || id.includes("\\")) {
6072
- throw new ValidationError("invalid node id");
6073
- }
6074
- if (id.startsWith("/")) {
6075
- throw new ValidationError("invalid node id (absolute path)");
6076
- }
6077
- for (const seg of id.split("/")) {
6078
- if (seg === "" || seg === "." || seg === "..") {
6079
- throw new ValidationError("invalid node id (path traversal)");
6080
- }
6081
- }
6082
- return id;
6083
- }
6084
-
6085
6096
  // src/telemetry/trace-log.ts
6086
6097
  var PRUNE_EVERY = 500;
6087
6098
  var insertsSincePrune = 0;
@@ -6385,6 +6396,30 @@ async function appendRunStep(runId, input) {
6385
6396
  return { seq };
6386
6397
  }
6387
6398
  var RUN_STATUSES = ["running", "succeeded", "failed", "cancelled"];
6399
+ async function cancelDescendants(runId) {
6400
+ const db = getDb();
6401
+ const at = (/* @__PURE__ */ new Date()).toISOString();
6402
+ const trace = JSON.stringify({ error: "Parent run cancelled" });
6403
+ let frontier = [runId];
6404
+ let cancelled = 0;
6405
+ for (let depth = 0; frontier.length && depth < 64; depth++) {
6406
+ const ph = frontier.map(() => "?").join(",");
6407
+ const kids = await db.all(
6408
+ `SELECT id FROM runs WHERE parent_run_id IN (${ph}) AND status = 'running'`,
6409
+ frontier
6410
+ );
6411
+ if (!kids.length) break;
6412
+ frontier = kids.map((k) => k.id);
6413
+ const kph = frontier.map(() => "?").join(",");
6414
+ await db.run(
6415
+ `UPDATE runs SET status = 'cancelled', trace = COALESCE(trace, ?), finished_at = ?
6416
+ WHERE id IN (${kph}) AND status = 'running'`,
6417
+ [trace, at, ...frontier]
6418
+ );
6419
+ cancelled += frontier.length;
6420
+ }
6421
+ return cancelled;
6422
+ }
6388
6423
  async function closeRun(runId, input) {
6389
6424
  const status = input.status;
6390
6425
  if (typeof status !== "string" || !RUN_STATUSES.includes(status)) {
@@ -6406,6 +6441,7 @@ async function closeRun(runId, input) {
6406
6441
  if (!run) throw new NotFoundError("Run not found");
6407
6442
  throw new ConflictError("Run already terminal \u2014 status can't be changed");
6408
6443
  }
6444
+ if (status === "cancelled") await cancelDescendants(runId);
6409
6445
  return { status };
6410
6446
  }
6411
6447
  async function getRunTrace(run, opts) {
@@ -7656,6 +7692,13 @@ async function handleToolCall(toolName, args, ctx) {
7656
7692
  try {
7657
7693
  return await runTool(toolName, args, ctx);
7658
7694
  } catch (err) {
7695
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
7696
+ status = 503;
7697
+ return toolError(
7698
+ "The vault is busy with another write. Retry in a few seconds.",
7699
+ "VAULT_BUSY"
7700
+ );
7701
+ }
7659
7702
  status = 500;
7660
7703
  throw err;
7661
7704
  } finally {
@@ -7709,7 +7752,9 @@ ${context_md}`;
7709
7752
  {},
7710
7753
  await opContext(nestId, userEmail)
7711
7754
  );
7712
- const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
7755
+ const visible = await listNodesForCallerByEmail(nestId, userEmail, {
7756
+ withContent: true
7757
+ });
7713
7758
  const types = {};
7714
7759
  const tags = {};
7715
7760
  for (const n of visible) {
@@ -8544,14 +8589,14 @@ ${list}`,
8544
8589
  if (!target) return "target is required (a node id or folder prefix).";
8545
8590
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8546
8591
  try {
8547
- const { createGrant: createGrant2 } = await import("./grants-service-4NXPBYWD.js");
8548
- const db = (await import("./client-OBS5HOTA.js")).getDb();
8592
+ const { createGrant: createGrant2 } = await import("./grants-service-CADI6LIB.js");
8593
+ const db = (await import("./client-SW5I6ZBK.js")).getDb();
8549
8594
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8550
8595
  const e = normalizeEmail2(String(args.email || ""));
8551
8596
  if (!e) return "email is required.";
8552
8597
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
8553
8598
  if (!row) {
8554
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
8599
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
8555
8600
  const { v4: uuid16 } = await import("uuid");
8556
8601
  const id = uuid16();
8557
8602
  await db.run(
@@ -9014,8 +9059,9 @@ runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
9014
9059
  throw new ValidationError("status must be running | succeeded | failed | cancelled");
9015
9060
  }
9016
9061
  const terminal = status !== "running";
9017
- await getDb().run(
9018
- `UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ? WHERE id = ?`,
9062
+ const res = await getDb().run(
9063
+ `UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ?
9064
+ WHERE id = ? AND status = 'running'`,
9019
9065
  [
9020
9066
  status,
9021
9067
  body.trace === void 0 ? null : JSON.stringify(body.trace),
@@ -9023,6 +9069,13 @@ runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
9023
9069
  run.id
9024
9070
  ]
9025
9071
  );
9072
+ if (!res.changes) {
9073
+ const now = await getDb().get("SELECT status FROM runs WHERE id = ?", [
9074
+ run.id
9075
+ ]);
9076
+ throw new ConflictError(`Run already terminal (${now?.status ?? "closed"})`);
9077
+ }
9078
+ if (status === "cancelled") await cancelDescendants(run.id);
9026
9079
  if (status === "failed") {
9027
9080
  const err = body.trace && typeof body.trace === "object" ? String(body.trace.error ?? "").slice(0, 200) : "";
9028
9081
  void dispatchEvent({
@@ -9631,8 +9684,8 @@ definitionRoutes.delete("/:id", async (c) => {
9631
9684
  // src/nests/asset-routes.ts
9632
9685
  import { Hono as Hono18 } from "hono";
9633
9686
  import { v4 as uuid13 } from "uuid";
9634
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
9635
- import { join as join5 } from "path";
9687
+ import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
9688
+ import { join as join4 } from "path";
9636
9689
  var assetRoutes = new Hono18();
9637
9690
  var CONTENT_TYPES = {
9638
9691
  png: "image/png",
@@ -9680,9 +9733,9 @@ assetRoutes.post("/", async (c) => {
9680
9733
  );
9681
9734
  }
9682
9735
  const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
9683
- const dir = join5(resolveNestPath(nestId), "assets");
9736
+ const dir = join4(resolveNestPath(nestId), "assets");
9684
9737
  await mkdir2(dir, { recursive: true });
9685
- await writeFile(join5(dir, name), Buffer.from(await file.arrayBuffer()));
9738
+ await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
9686
9739
  const url = `/nests/${nestId}/assets/${name}`;
9687
9740
  return c.json(
9688
9741
  {
@@ -9704,7 +9757,7 @@ assetRoutes.get("/:file", async (c) => {
9704
9757
  }
9705
9758
  let bytes;
9706
9759
  try {
9707
- bytes = await readFile2(join5(resolveNestPath(nestId), "assets", name));
9760
+ bytes = await readFile(join4(resolveNestPath(nestId), "assets", name));
9708
9761
  } catch {
9709
9762
  throw new NotFoundError("Asset not found");
9710
9763
  }
@@ -10514,7 +10567,7 @@ function rowToComment(row) {
10514
10567
 
10515
10568
  // src/governance/stewards-parser.ts
10516
10569
  import { readFileSync as readFileSync3, existsSync } from "fs";
10517
- import { join as join6 } from "path";
10570
+ import { join as join5 } from "path";
10518
10571
  function parseStewardsYaml(content) {
10519
10572
  const result = { version: 1 };
10520
10573
  const lines = content.split("\n");
@@ -10580,9 +10633,9 @@ function parseEntry(str) {
10580
10633
  function loadStewardsConfig(nestId) {
10581
10634
  const nestPath = resolveNestPath(nestId);
10582
10635
  const candidates = [
10583
- join6(nestPath, "stewards.yaml"),
10584
- join6(nestPath, "stewards.yml"),
10585
- join6(nestPath, ".context", "stewards.yaml")
10636
+ join5(nestPath, "stewards.yaml"),
10637
+ join5(nestPath, "stewards.yml"),
10638
+ join5(nestPath, ".context", "stewards.yaml")
10586
10639
  ];
10587
10640
  for (const candidatePath of candidates) {
10588
10641
  if (existsSync(candidatePath)) {
@@ -10943,7 +10996,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10943
10996
  const approved = await getApprovedVersion(nestId, nodeId);
10944
10997
  const { versions: versionManager } = await engineCache.get(nestId);
10945
10998
  const resolutions = await getDb().all(
10946
- `SELECT version, status, resolved_by
10999
+ `SELECT version, status, resolved_by, resolution_note
10947
11000
  FROM review_requests
10948
11001
  WHERE nest_id = ? AND node_id = ?
10949
11002
  AND status IN ('approved', 'rejected')
@@ -10954,13 +11007,21 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10954
11007
  const byVersion = /* @__PURE__ */ new Map();
10955
11008
  for (const r of resolutions) {
10956
11009
  if (!byVersion.has(r.version)) {
10957
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
11010
+ byVersion.set(r.version, {
11011
+ status: r.status,
11012
+ resolvedBy: r.resolved_by,
11013
+ resolutionNote: r.resolution_note?.trim() || void 0
11014
+ });
10958
11015
  }
10959
11016
  }
10960
11017
  const enriched = await Promise.all(
10961
11018
  allVersions.map(async (v) => {
10962
11019
  const r = byVersion.get(v.version);
10963
- const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
11020
+ const resolution = r ? {
11021
+ resolvedBy: r.resolvedBy,
11022
+ resolutionStatus: r.status,
11023
+ resolutionNote: r.resolutionNote
11024
+ } : {};
10964
11025
  try {
10965
11026
  const diff = await versionManager.getDiff(nodeId, v.version);
10966
11027
  return { ...v, ...resolution, diff: diff ?? void 0 };
@@ -11338,19 +11399,19 @@ async function ensureAnonymousUser() {
11338
11399
  // src/app.ts
11339
11400
  import { serveStatic } from "@hono/node-server/serve-static";
11340
11401
  import { fileURLToPath } from "url";
11341
- import { dirname as dirname2, join as join7, relative as relative2 } from "path";
11402
+ import { dirname as dirname2, join as join6, relative as relative2 } from "path";
11342
11403
  import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
11343
11404
  var HERE = dirname2(fileURLToPath(import.meta.url));
11344
11405
  var SERVICE_VERSION = (() => {
11345
11406
  try {
11346
- return JSON.parse(readFileSync4(join7(HERE, "..", "package.json"), "utf8")).version;
11407
+ return JSON.parse(readFileSync4(join6(HERE, "..", "package.json"), "utf8")).version;
11347
11408
  } catch {
11348
11409
  return "unknown";
11349
11410
  }
11350
11411
  })();
11351
11412
  var UI_DIR_CANDIDATES = [
11352
- join7(HERE, "web3"),
11353
- join7(process.cwd(), "dist", "web3")
11413
+ join6(HERE, "web3"),
11414
+ join6(process.cwd(), "dist", "web3")
11354
11415
  ];
11355
11416
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
11356
11417
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
@@ -12508,6 +12569,12 @@ function createApp() {
12508
12569
  );
12509
12570
  app.notFound((c) => c.json({ error: "Not found" }, 404));
12510
12571
  app.onError((err, c) => {
12572
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
12573
+ const busy = err instanceof VaultBusyError ? err : new VaultBusyError();
12574
+ return c.json({ error: busy.message }, 503, {
12575
+ "Retry-After": String(busy.retryAfterSeconds)
12576
+ });
12577
+ }
12511
12578
  if (err instanceof AppError) {
12512
12579
  return c.json({ error: err.message }, err.statusCode);
12513
12580
  }