@promptowl/contextnest-community 1.14.0 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +15 -0
- package/dist/chunk-CLKZZZS2.js +624 -0
- package/dist/{chunk-JP67WXLA.js → chunk-D6HICFVW.js} +77 -3
- package/dist/{chunk-I5KYGMIT.js → chunk-JMJLNEXE.js} +78 -1
- package/dist/{chunk-KPK656BH.js → chunk-L7UCMGWZ.js} +478 -924
- package/dist/{chunk-IHGPZ6NY.js → chunk-OTGZQZKK.js} +1 -1
- package/dist/{chunk-MZ4IR5KH.js → chunk-QJVRZT6M.js} +356 -76
- package/dist/{client-3MGIP57D.js → client-OBS5HOTA.js} +1 -1
- package/dist/{grants-service-5ZFISCBH.js → grants-service-I535QVTW.js} +2 -2
- package/dist/index.js +2419 -1061
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-V2GR6VM3.js +47 -0
- package/dist/{stewardship-service-G7IF7ZNP.js → stewardship-service-LTREGVSJ.js} +4 -4
- package/dist/{version-service-FGL47APL.js → version-service-V6K5SZ3P.js} +11 -3
- package/dist/web3/assets/index-DnQARUL3.css +1 -0
- package/dist/web3/assets/index-_m7WyozM.js +1051 -0
- package/dist/web3/index.html +2 -2
- package/package.json +4 -3
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-6G7MVTY6.js +0 -31
- package/dist/web3/assets/index-BYmOkm4p.js +0 -1052
- package/dist/web3/assets/index-p6uQyhml.css +0 -1
package/dist/index.js
CHANGED
|
@@ -4,36 +4,42 @@ import {
|
|
|
4
4
|
approve,
|
|
5
5
|
canReadNode,
|
|
6
6
|
cancelReview,
|
|
7
|
+
declineDeletion,
|
|
8
|
+
deletionTargetTitle,
|
|
7
9
|
dispatchEvent,
|
|
10
|
+
dropDeletionRequest,
|
|
8
11
|
edgeTypeRoutes,
|
|
9
12
|
edgeTypeToResponse,
|
|
10
13
|
envRoutes,
|
|
11
14
|
envValues,
|
|
12
15
|
filterAccessible,
|
|
16
|
+
getDeletionRequest,
|
|
17
|
+
getLatestDeletionRequest,
|
|
13
18
|
getPendingReview,
|
|
14
19
|
getReviewHistory,
|
|
15
20
|
getReviewQueue,
|
|
16
21
|
isSafeConnectorUrl,
|
|
22
|
+
listDeletionRequests,
|
|
17
23
|
listNotifications,
|
|
18
24
|
listWatchers,
|
|
19
25
|
markNotificationsRead,
|
|
20
26
|
nodeWriteKey,
|
|
27
|
+
notifyDeletionResolved,
|
|
21
28
|
notifyNestEvent,
|
|
22
29
|
notifyNestInvite,
|
|
23
30
|
parseConditionSchema,
|
|
24
31
|
reject,
|
|
25
32
|
removeWatcher,
|
|
26
33
|
repointPendingReview,
|
|
34
|
+
requestDeletion,
|
|
27
35
|
requireWorkflowPlane,
|
|
28
36
|
resolveCallerEmail,
|
|
29
37
|
safeJson,
|
|
30
|
-
safePublishDocument,
|
|
31
38
|
seedDefaultEdgeTypes,
|
|
32
39
|
sendTestNotification,
|
|
33
|
-
stripUndefinedDeep,
|
|
34
40
|
submitForReview,
|
|
35
41
|
withNodeWriteLock
|
|
36
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-QJVRZT6M.js";
|
|
37
43
|
import {
|
|
38
44
|
createGrant,
|
|
39
45
|
deleteGrant,
|
|
@@ -42,7 +48,7 @@ import {
|
|
|
42
48
|
listGrants,
|
|
43
49
|
listUserGrants,
|
|
44
50
|
resolveNodeGrant
|
|
45
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-OTGZQZKK.js";
|
|
46
52
|
import {
|
|
47
53
|
generateApiKey,
|
|
48
54
|
getKeyPrefix,
|
|
@@ -55,39 +61,56 @@ import {
|
|
|
55
61
|
checkConflict,
|
|
56
62
|
createVersion,
|
|
57
63
|
getApprovedVersion,
|
|
64
|
+
getApprovedVersions,
|
|
58
65
|
getCurrentVersion,
|
|
59
66
|
getDisplayStatus,
|
|
67
|
+
getTrackedNodeIds,
|
|
60
68
|
getVersions,
|
|
69
|
+
listMyDrafts,
|
|
61
70
|
setApprovedVersion,
|
|
62
71
|
upsertVersion
|
|
63
|
-
} from "./chunk-
|
|
72
|
+
} from "./chunk-D6HICFVW.js";
|
|
64
73
|
import {
|
|
65
|
-
addMember,
|
|
66
|
-
buildTitleMap,
|
|
67
74
|
canCreateInNest,
|
|
68
75
|
canManageStewards,
|
|
69
76
|
canUserAccess,
|
|
70
77
|
canUserApprove,
|
|
71
78
|
canUserEdit,
|
|
72
|
-
createNest,
|
|
73
79
|
createStewardRecord,
|
|
80
|
+
getCollaboratorRole,
|
|
81
|
+
getStewardRolesForUser,
|
|
82
|
+
getStewardsForNest,
|
|
83
|
+
getStewardsForUser,
|
|
84
|
+
listStewards,
|
|
85
|
+
removeSteward,
|
|
86
|
+
resolveGrantRoles,
|
|
87
|
+
resolveStewardsForNode,
|
|
88
|
+
resolveStewardsWithFallback,
|
|
89
|
+
resolveUserRoles,
|
|
90
|
+
syncFromConfig,
|
|
91
|
+
updateSteward
|
|
92
|
+
} from "./chunk-CLKZZZS2.js";
|
|
93
|
+
import {
|
|
94
|
+
addMember,
|
|
95
|
+
buildTitleMap,
|
|
96
|
+
canApproveWith,
|
|
97
|
+
createNest,
|
|
74
98
|
createTeam,
|
|
75
99
|
deleteNest,
|
|
76
100
|
deleteTeam,
|
|
77
101
|
disableStewardshipAndWipeGovernance,
|
|
78
102
|
docLink,
|
|
103
|
+
engineApi,
|
|
79
104
|
engineCache,
|
|
80
105
|
findTeamByExternalId,
|
|
106
|
+
folderForNode,
|
|
81
107
|
getAccessConfig,
|
|
82
|
-
getCollaboratorRole,
|
|
83
108
|
getCurrentLicense,
|
|
84
109
|
getNest,
|
|
85
|
-
getStewardRolesForUser,
|
|
86
|
-
getStewardsForNest,
|
|
87
|
-
getStewardsForUser,
|
|
88
110
|
getSuspensionReason,
|
|
89
111
|
getTeam,
|
|
90
112
|
importNest,
|
|
113
|
+
insertOrIgnore,
|
|
91
114
|
installLicenseKey,
|
|
92
115
|
isConfigSuperAdmin,
|
|
93
116
|
isLicenseAdminEmail,
|
|
@@ -101,7 +124,6 @@ import {
|
|
|
101
124
|
listNests,
|
|
102
125
|
listPublicNests,
|
|
103
126
|
listSharedNests,
|
|
104
|
-
listStewards,
|
|
105
127
|
listTeamsForUser,
|
|
106
128
|
listVisibleNests,
|
|
107
129
|
loadAccessConfig,
|
|
@@ -109,36 +131,38 @@ import {
|
|
|
109
131
|
loadServerSettings,
|
|
110
132
|
nestAllowsSelfApprove,
|
|
111
133
|
nestName,
|
|
134
|
+
nestPrimeTags,
|
|
135
|
+
nestReviewsPrimeOnly,
|
|
112
136
|
nestStorageRoot,
|
|
137
|
+
normalizeTag,
|
|
138
|
+
nowExpr,
|
|
139
|
+
opContext,
|
|
113
140
|
permissionLevel,
|
|
114
141
|
persistSetting,
|
|
115
142
|
removeMember,
|
|
116
|
-
|
|
143
|
+
removeNodeFromTagIndex,
|
|
117
144
|
renameNest,
|
|
118
145
|
renameTeam,
|
|
119
146
|
replaceTeamRoster,
|
|
120
|
-
resolveGrantRoles,
|
|
121
147
|
resolveMemberIdentity,
|
|
122
148
|
resolveNestPath,
|
|
123
149
|
resolveNestPermission,
|
|
124
|
-
resolveStewardsForNode,
|
|
125
|
-
resolveStewardsWithFallback,
|
|
126
|
-
resolveUserRoles,
|
|
127
150
|
sendEmailToRecipient,
|
|
128
151
|
setAllowSelfApprove,
|
|
152
|
+
setPrimeOnlyReview,
|
|
153
|
+
setPrimeTags,
|
|
129
154
|
setStewardshipEnabled,
|
|
130
155
|
shareTeamWithNest,
|
|
131
156
|
startLicenseSafetyPoll,
|
|
132
157
|
startTelemetryLoop,
|
|
133
|
-
|
|
158
|
+
syncNodeTags,
|
|
134
159
|
trackEvent,
|
|
135
160
|
uniqueNestName,
|
|
136
161
|
unshareTeamFromNest,
|
|
137
162
|
updateMemberRole,
|
|
138
|
-
updateSteward,
|
|
139
163
|
validateLicense,
|
|
140
164
|
verifySmtp
|
|
141
|
-
} from "./chunk-
|
|
165
|
+
} from "./chunk-L7UCMGWZ.js";
|
|
142
166
|
import {
|
|
143
167
|
AppError,
|
|
144
168
|
ConflictError,
|
|
@@ -148,24 +172,20 @@ import {
|
|
|
148
172
|
UnauthorizedError,
|
|
149
173
|
ValidationError
|
|
150
174
|
} from "./chunk-GUNJTORH.js";
|
|
151
|
-
import {
|
|
152
|
-
normalizeEmail
|
|
153
|
-
} from "./chunk-FRQJWGN3.js";
|
|
154
|
-
import {
|
|
155
|
-
insertOrIgnore,
|
|
156
|
-
nowExpr
|
|
157
|
-
} from "./chunk-XQ46F76G.js";
|
|
158
175
|
import {
|
|
159
176
|
config,
|
|
160
177
|
getDb,
|
|
161
178
|
initDb,
|
|
162
179
|
isEmailListish,
|
|
163
180
|
isEmailish
|
|
164
|
-
} from "./chunk-
|
|
181
|
+
} from "./chunk-JMJLNEXE.js";
|
|
165
182
|
import {
|
|
166
183
|
ANON_EMAIL,
|
|
167
184
|
ANON_USER_ID
|
|
168
185
|
} from "./chunk-SLTQACJW.js";
|
|
186
|
+
import {
|
|
187
|
+
normalizeEmail
|
|
188
|
+
} from "./chunk-FRQJWGN3.js";
|
|
169
189
|
|
|
170
190
|
// src/index.ts
|
|
171
191
|
import { serve } from "@hono/node-server";
|
|
@@ -176,6 +196,29 @@ import { LinearRouter } from "hono/router/linear-router";
|
|
|
176
196
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
177
197
|
import { cors } from "hono/cors";
|
|
178
198
|
|
|
199
|
+
// src/shared/artifact-sandbox.ts
|
|
200
|
+
var ARTIFACT_SANDBOX_CSP = [
|
|
201
|
+
"sandbox allow-scripts allow-popups allow-modals",
|
|
202
|
+
"default-src 'none'",
|
|
203
|
+
"script-src 'unsafe-inline'",
|
|
204
|
+
"style-src 'unsafe-inline'",
|
|
205
|
+
"img-src data: blob:",
|
|
206
|
+
"font-src data:",
|
|
207
|
+
"connect-src 'none'",
|
|
208
|
+
"base-uri 'none'",
|
|
209
|
+
"form-action 'none'"
|
|
210
|
+
].join("; ");
|
|
211
|
+
function mergeFrameAncestors(existing, directive) {
|
|
212
|
+
if (!existing) return directive;
|
|
213
|
+
if (/(^|;)\s*frame-ancestors\b/i.test(existing)) return existing;
|
|
214
|
+
return `${existing}; ${directive}`;
|
|
215
|
+
}
|
|
216
|
+
function applyArtifactSandboxHeaders(c) {
|
|
217
|
+
c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
|
|
218
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
219
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
220
|
+
}
|
|
221
|
+
|
|
179
222
|
// src/auth/routes.ts
|
|
180
223
|
import { Hono as Hono2 } from "hono";
|
|
181
224
|
import { v4 as uuid2 } from "uuid";
|
|
@@ -393,6 +436,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
|
|
|
393
436
|
|
|
394
437
|
// src/auth/sso-token.ts
|
|
395
438
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
439
|
+
function isValidTicketSub(sub) {
|
|
440
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
|
|
441
|
+
}
|
|
396
442
|
function b64urlToBuffer(input) {
|
|
397
443
|
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
398
444
|
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
@@ -1215,6 +1261,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1215
1261
|
} catch {
|
|
1216
1262
|
return ssoError("invalid_ticket");
|
|
1217
1263
|
}
|
|
1264
|
+
if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
|
|
1218
1265
|
const expectedAud = config.PUBLIC_BASE_URL;
|
|
1219
1266
|
if (!expectedAud && !warnedMissingSsoAudience) {
|
|
1220
1267
|
warnedMissingSsoAudience = true;
|
|
@@ -1227,7 +1274,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1227
1274
|
if (aud !== expectedAud) return ssoError("bad_audience");
|
|
1228
1275
|
}
|
|
1229
1276
|
const sub = (claims.sub || "").trim();
|
|
1230
|
-
if (
|
|
1277
|
+
if (!isValidTicketSub(sub)) {
|
|
1231
1278
|
return ssoError("invalid_ticket");
|
|
1232
1279
|
}
|
|
1233
1280
|
if (!claims.jti) return ssoError("invalid_ticket");
|
|
@@ -1557,48 +1604,58 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1557
1604
|
|
|
1558
1605
|
// src/nests/routes.ts
|
|
1559
1606
|
import { Hono as Hono3 } from "hono";
|
|
1560
|
-
import { streamText } from "hono/streaming";
|
|
1561
1607
|
|
|
1562
1608
|
// src/nodes/service.ts
|
|
1563
1609
|
import {
|
|
1564
|
-
serializeDocument,
|
|
1565
1610
|
parseDocument as parseDocument2,
|
|
1566
|
-
normalizeStatus
|
|
1611
|
+
normalizeStatus,
|
|
1612
|
+
isRejected,
|
|
1613
|
+
DocumentNotFoundError
|
|
1567
1614
|
} from "@promptowl/contextnest-engine";
|
|
1568
|
-
import { rename, mkdir
|
|
1569
|
-
import { dirname, join as join2
|
|
1615
|
+
import { rename, mkdir } from "fs/promises";
|
|
1616
|
+
import { dirname, join as join2 } from "path";
|
|
1570
1617
|
|
|
1571
|
-
// src/governance/
|
|
1572
|
-
|
|
1573
|
-
|
|
1618
|
+
// src/governance/safe-publish.ts
|
|
1619
|
+
import {
|
|
1620
|
+
publishDocument,
|
|
1621
|
+
serializeDocument
|
|
1622
|
+
} from "@promptowl/contextnest-engine";
|
|
1623
|
+
function serializeNodeSafe(node) {
|
|
1624
|
+
return serializeDocument({
|
|
1625
|
+
...node,
|
|
1626
|
+
frontmatter: stripUndefinedDeep(node.frontmatter)
|
|
1627
|
+
});
|
|
1574
1628
|
}
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
"INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1585
|
-
);
|
|
1586
|
-
await db.transaction(async (tx) => {
|
|
1587
|
-
await tx.run(
|
|
1588
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1589
|
-
[nestId, nodeId]
|
|
1590
|
-
);
|
|
1591
|
-
for (const tag of normalized) {
|
|
1592
|
-
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1629
|
+
function stripUndefinedDeep(value) {
|
|
1630
|
+
if (Array.isArray(value)) {
|
|
1631
|
+
return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
|
|
1632
|
+
}
|
|
1633
|
+
if (value && typeof value === "object") {
|
|
1634
|
+
const out = {};
|
|
1635
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1636
|
+
if (v === void 0) continue;
|
|
1637
|
+
out[k] = stripUndefinedDeep(v);
|
|
1593
1638
|
}
|
|
1594
|
-
|
|
1639
|
+
return out;
|
|
1640
|
+
}
|
|
1641
|
+
return value;
|
|
1595
1642
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
);
|
|
1643
|
+
|
|
1644
|
+
// src/governance/prime-service.ts
|
|
1645
|
+
async function isPrimeDocument(nestId, tags, metadata) {
|
|
1646
|
+
const flag = documentPrimeFlag(metadata);
|
|
1647
|
+
if (flag !== void 0) return flag;
|
|
1648
|
+
const primeTags = await nestPrimeTags(nestId);
|
|
1649
|
+
if (!primeTags.length) return false;
|
|
1650
|
+
const wanted = new Set(primeTags);
|
|
1651
|
+
return (tags || []).some((t) => wanted.has(normalizeTag(String(t))));
|
|
1652
|
+
}
|
|
1653
|
+
function documentPrimeFlag(metadata) {
|
|
1654
|
+
const raw = metadata?.prime;
|
|
1655
|
+
if (typeof raw === "boolean") return raw;
|
|
1656
|
+
if (raw === "true") return true;
|
|
1657
|
+
if (raw === "false") return false;
|
|
1658
|
+
return void 0;
|
|
1602
1659
|
}
|
|
1603
1660
|
|
|
1604
1661
|
// src/governance/external-edit-service.ts
|
|
@@ -1607,6 +1664,7 @@ import { join } from "path";
|
|
|
1607
1664
|
import {
|
|
1608
1665
|
detectDrift,
|
|
1609
1666
|
stageSuggestion,
|
|
1667
|
+
ChainEventLog,
|
|
1610
1668
|
approveSuggestion,
|
|
1611
1669
|
rejectSuggestion,
|
|
1612
1670
|
listSuggestions,
|
|
@@ -1689,7 +1747,8 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
|
1689
1747
|
return { meta: result.meta, created: true };
|
|
1690
1748
|
}
|
|
1691
1749
|
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
1692
|
-
const { storage } = await engineCache.get(nestId);
|
|
1750
|
+
const { storage, invalidateDiscovery } = await engineCache.get(nestId);
|
|
1751
|
+
invalidateDiscovery();
|
|
1693
1752
|
const docs = await storage.discoverDocuments();
|
|
1694
1753
|
const results = await Promise.all(
|
|
1695
1754
|
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
@@ -1760,6 +1819,68 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
|
1760
1819
|
patch: found.patch
|
|
1761
1820
|
};
|
|
1762
1821
|
}
|
|
1822
|
+
async function recordVerdict(nestId, event) {
|
|
1823
|
+
try {
|
|
1824
|
+
const { storage } = await engineCache.get(nestId);
|
|
1825
|
+
await new ChainEventLog(storage).append(event);
|
|
1826
|
+
return true;
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
console.error(
|
|
1829
|
+
`[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
|
|
1830
|
+
err
|
|
1831
|
+
);
|
|
1832
|
+
return false;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
async function listExternalEditVerdicts(nestId, documentId) {
|
|
1836
|
+
try {
|
|
1837
|
+
const { storage } = await engineCache.get(nestId);
|
|
1838
|
+
const events = await new ChainEventLog(storage).readByDocument(documentId);
|
|
1839
|
+
return events.flatMap((e) => {
|
|
1840
|
+
const meta = e.action_metadata || {};
|
|
1841
|
+
if (typeof meta.suggestion_id !== "string") return [];
|
|
1842
|
+
const reason = meta.rejection_reason;
|
|
1843
|
+
const comment = meta.approval_comment;
|
|
1844
|
+
return [
|
|
1845
|
+
{
|
|
1846
|
+
suggestion_id: meta.suggestion_id,
|
|
1847
|
+
status: typeof reason === "string" ? "rejected" : "approved",
|
|
1848
|
+
actor: e.actor,
|
|
1849
|
+
at: e.timestamp,
|
|
1850
|
+
note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
|
|
1851
|
+
source: typeof meta.source === "string" ? meta.source : void 0
|
|
1852
|
+
}
|
|
1853
|
+
];
|
|
1854
|
+
});
|
|
1855
|
+
} catch (err) {
|
|
1856
|
+
console.error(
|
|
1857
|
+
`[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
|
|
1858
|
+
err
|
|
1859
|
+
);
|
|
1860
|
+
return [];
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
async function mirrorVersion(input) {
|
|
1864
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
1865
|
+
const node = await storage.readDocument(input.documentId);
|
|
1866
|
+
const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-V6K5SZ3P.js");
|
|
1867
|
+
await upsertVersion2({
|
|
1868
|
+
nestId: input.nestId,
|
|
1869
|
+
nodeId: input.documentId,
|
|
1870
|
+
version: input.version,
|
|
1871
|
+
content: node.body || "",
|
|
1872
|
+
author: input.actor,
|
|
1873
|
+
status: "published",
|
|
1874
|
+
tags: node.frontmatter.tags || [],
|
|
1875
|
+
changeNote: input.note
|
|
1876
|
+
});
|
|
1877
|
+
await setApprovedVersion2(
|
|
1878
|
+
input.nestId,
|
|
1879
|
+
input.documentId,
|
|
1880
|
+
input.version,
|
|
1881
|
+
input.actor
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1763
1884
|
async function approveExternalEdit(input) {
|
|
1764
1885
|
const { storage } = await engineCache.get(input.nestId);
|
|
1765
1886
|
let result;
|
|
@@ -1781,33 +1902,21 @@ async function approveExternalEdit(input) {
|
|
|
1781
1902
|
throw err;
|
|
1782
1903
|
}
|
|
1783
1904
|
try {
|
|
1784
|
-
|
|
1785
|
-
const versionNum = result.versionEntry.version;
|
|
1786
|
-
const tags = node.frontmatter.tags || [];
|
|
1787
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FGL47APL.js");
|
|
1788
|
-
await createVersion2({
|
|
1905
|
+
await mirrorVersion({
|
|
1789
1906
|
nestId: input.nestId,
|
|
1790
|
-
|
|
1791
|
-
version:
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
status: "published",
|
|
1795
|
-
tags,
|
|
1796
|
-
changeNote: input.comment || "External edit approved"
|
|
1907
|
+
documentId: input.documentId,
|
|
1908
|
+
version: result.versionEntry.version,
|
|
1909
|
+
actor: input.actor,
|
|
1910
|
+
note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
|
|
1797
1911
|
});
|
|
1798
|
-
await setApprovedVersion2(
|
|
1799
|
-
input.nestId,
|
|
1800
|
-
input.documentId,
|
|
1801
|
-
versionNum,
|
|
1802
|
-
input.actor
|
|
1803
|
-
);
|
|
1804
1912
|
} catch (err) {
|
|
1805
1913
|
console.error(
|
|
1806
1914
|
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
1807
1915
|
err
|
|
1808
1916
|
);
|
|
1809
1917
|
}
|
|
1810
|
-
|
|
1918
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
1919
|
+
return { ...result, historyRecorded };
|
|
1811
1920
|
}
|
|
1812
1921
|
async function rejectExternalEdit(input) {
|
|
1813
1922
|
const { storage } = await engineCache.get(input.nestId);
|
|
@@ -1820,6 +1929,7 @@ async function rejectExternalEdit(input) {
|
|
|
1820
1929
|
zone: "default",
|
|
1821
1930
|
reason: input.reason
|
|
1822
1931
|
});
|
|
1932
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
1823
1933
|
try {
|
|
1824
1934
|
const approved = await loadChainHead(storage, input.documentId);
|
|
1825
1935
|
if (approved) {
|
|
@@ -1831,7 +1941,7 @@ async function rejectExternalEdit(input) {
|
|
|
1831
1941
|
err
|
|
1832
1942
|
);
|
|
1833
1943
|
}
|
|
1834
|
-
return result;
|
|
1944
|
+
return { ...result, historyRecorded };
|
|
1835
1945
|
}
|
|
1836
1946
|
var scannerTimer = null;
|
|
1837
1947
|
async function scanAllNests() {
|
|
@@ -1865,9 +1975,33 @@ async function userIdFromEmail(email) {
|
|
|
1865
1975
|
return row?.id ?? ANON_USER_ID;
|
|
1866
1976
|
}
|
|
1867
1977
|
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1978
|
+
var MAX_TITLE_LENGTH = 120;
|
|
1979
|
+
function assertTitleLength(title) {
|
|
1980
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
1981
|
+
throw new ValidationError(
|
|
1982
|
+
`title must be at most ${MAX_TITLE_LENGTH} characters`
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
function assertTitleSluggable(title) {
|
|
1987
|
+
if (!slugify(title)) {
|
|
1988
|
+
throw new ValidationError(
|
|
1989
|
+
"the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1868
1993
|
var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1994
|
+
function isIdUnderFolder(id, folder) {
|
|
1995
|
+
return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
|
|
1996
|
+
}
|
|
1869
1997
|
function folderToSegments(folder) {
|
|
1870
1998
|
const segments = folder.split("/").map(slugify).filter(Boolean);
|
|
1999
|
+
if (folder.trim() && segments.length === 0) {
|
|
2000
|
+
throw new ValidationError(
|
|
2001
|
+
"the folder path is built from the name using a-z and 0-9, so the name needs at least one of those"
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
if (segments[0] === "nodes") segments.shift();
|
|
1871
2005
|
if (segments.length > 8) {
|
|
1872
2006
|
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1873
2007
|
}
|
|
@@ -1878,7 +2012,6 @@ function folderToSegments(folder) {
|
|
|
1878
2012
|
}
|
|
1879
2013
|
return segments;
|
|
1880
2014
|
}
|
|
1881
|
-
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1882
2015
|
async function departmentTagFor(userEmail) {
|
|
1883
2016
|
if (!config.OIDC_DEPARTMENT_TAGGING) return null;
|
|
1884
2017
|
try {
|
|
@@ -1895,6 +2028,12 @@ async function departmentTagFor(userEmail) {
|
|
|
1895
2028
|
}
|
|
1896
2029
|
function toSafeError(err, context, message) {
|
|
1897
2030
|
if (err instanceof AppError) return err;
|
|
2031
|
+
if (err?.code === "REJECTED_DOCUMENT") {
|
|
2032
|
+
console.error(`${context}:`, err);
|
|
2033
|
+
return new ConflictError(
|
|
2034
|
+
"This document is rejected. Change its status before saving."
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
1898
2037
|
console.error(`${context}:`, err);
|
|
1899
2038
|
return new AppError(500, message);
|
|
1900
2039
|
}
|
|
@@ -1928,7 +2067,8 @@ function toNodeResponse(node) {
|
|
|
1928
2067
|
pendingChange: node.pendingChange ?? void 0,
|
|
1929
2068
|
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1930
2069
|
// number — normalize to string at the API boundary.
|
|
1931
|
-
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
|
|
2070
|
+
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
|
|
2071
|
+
prime: documentPrimeFlag(fm.metadata)
|
|
1932
2072
|
};
|
|
1933
2073
|
}
|
|
1934
2074
|
async function resolveAuthorship(nestId, nodes) {
|
|
@@ -1984,28 +2124,89 @@ async function resolveAuthorship(nestId, nodes) {
|
|
|
1984
2124
|
}
|
|
1985
2125
|
return result;
|
|
1986
2126
|
}
|
|
1987
|
-
async function
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2127
|
+
async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
|
|
2128
|
+
if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
|
|
2129
|
+
throw new ForbiddenError(
|
|
2130
|
+
"Access denied \u2014 no steward assignment for this node"
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
let got;
|
|
2134
|
+
try {
|
|
2135
|
+
got = await engineApi.run(
|
|
2136
|
+
"context_get",
|
|
2137
|
+
{
|
|
2138
|
+
...selector,
|
|
2139
|
+
include_raw: true,
|
|
2140
|
+
// A retired node stays readable here: its stewards decide whether to
|
|
2141
|
+
// revive it, and they cannot do that if the read refuses.
|
|
2142
|
+
allow_rejected: true,
|
|
2143
|
+
...opts.verifyChecksum ? { verify_checksum: true } : {}
|
|
2144
|
+
},
|
|
2145
|
+
await opContext(nestId, userEmail)
|
|
1993
2146
|
);
|
|
1994
|
-
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
if (err?.code !== "DOCUMENT_NOT_FOUND") {
|
|
2149
|
+
console.error(`getNodeForCaller failed (${nestId}/${selector.id ?? selector.title})`, err);
|
|
2150
|
+
}
|
|
2151
|
+
throw new NotFoundError(`Node not found: ${selector.id ?? selector.title}`);
|
|
1995
2152
|
}
|
|
1996
|
-
if (
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
(n) => (n.frontmatter.tags || []).includes(tag)
|
|
2153
|
+
if (!selector.id && !await canReadNode(nestId, got.id, userId, userEmail)) {
|
|
2154
|
+
throw new ForbiddenError(
|
|
2155
|
+
"Access denied \u2014 no steward assignment for this node"
|
|
2000
2156
|
);
|
|
2001
2157
|
}
|
|
2158
|
+
return {
|
|
2159
|
+
id: got.id,
|
|
2160
|
+
filePath: "",
|
|
2161
|
+
rawContent: got.raw ?? "",
|
|
2162
|
+
frontmatter: got.frontmatter,
|
|
2163
|
+
body: got.body,
|
|
2164
|
+
...got.pendingChange ? { pendingChange: got.pendingChange } : {}
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
2168
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
2002
2169
|
const userEmail = await resolveCallerEmail(userId);
|
|
2003
|
-
|
|
2170
|
+
const { documents } = await engineApi.run(
|
|
2171
|
+
"context_list",
|
|
2172
|
+
{
|
|
2173
|
+
...filters.type ? { type: filters.type } : {},
|
|
2174
|
+
...filters.tag ? { tag: filters.tag } : {},
|
|
2175
|
+
...filters.status ? { status: filters.status } : {},
|
|
2176
|
+
// Unlike the CLI and mcp-server, a retired node stays in this listing:
|
|
2177
|
+
// here it is an unpublished document its stewards may still need to act
|
|
2178
|
+
// on, not one removed from retrieval. An imported vault carrying
|
|
2179
|
+
// `status: rejected` registers exactly that way.
|
|
2180
|
+
include_retired: true,
|
|
2181
|
+
full: true
|
|
2182
|
+
},
|
|
2183
|
+
await opContext(nestId, userEmail)
|
|
2184
|
+
);
|
|
2185
|
+
const nodes = documents.map((d) => ({
|
|
2186
|
+
id: d.id,
|
|
2187
|
+
filePath: "",
|
|
2188
|
+
rawContent: "",
|
|
2189
|
+
frontmatter: d.frontmatter,
|
|
2190
|
+
body: d.body
|
|
2191
|
+
}));
|
|
2192
|
+
const publicReader = await isPublicReader(nestId, userId);
|
|
2193
|
+
const approvedOnly = publicReader || filters.approvedOnly === true;
|
|
2194
|
+
const approvedVersions = approvedOnly ? await getApprovedVersions(nestId) : null;
|
|
2195
|
+
let accessible = await filterAccessible(
|
|
2196
|
+
nestId,
|
|
2197
|
+
userId,
|
|
2198
|
+
userEmail,
|
|
2199
|
+
nodes,
|
|
2200
|
+
approvedVersions ?? void 0
|
|
2201
|
+
);
|
|
2004
2202
|
if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
|
|
2005
2203
|
const grants = await listUserGrants(nestId, userId);
|
|
2006
|
-
accessible = grants.length ?
|
|
2204
|
+
accessible = grants.length ? nodes.filter((d) => grantCoversNode(grants, d.id)) : accessible;
|
|
2007
2205
|
}
|
|
2008
|
-
|
|
2206
|
+
if (approvedVersions && !publicReader) {
|
|
2207
|
+
accessible = accessible.filter((d) => approvedVersions.has(d.id));
|
|
2208
|
+
}
|
|
2209
|
+
if (filters.limit) accessible = accessible.slice(0, filters.limit);
|
|
2009
2210
|
const authorship = publicReader ? null : await resolveAuthorship(
|
|
2010
2211
|
nestId,
|
|
2011
2212
|
accessible.map((d) => ({
|
|
@@ -2021,8 +2222,8 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
2021
2222
|
r.author = people.author;
|
|
2022
2223
|
r.collaborators = people.collaborators;
|
|
2023
2224
|
}
|
|
2024
|
-
if (
|
|
2025
|
-
const approved =
|
|
2225
|
+
if (approvedVersions) {
|
|
2226
|
+
const approved = approvedVersions.get(doc.id);
|
|
2026
2227
|
if (approved != null) {
|
|
2027
2228
|
try {
|
|
2028
2229
|
const raw = await versionManager.reconstructVersion(
|
|
@@ -2059,7 +2260,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
2059
2260
|
return r;
|
|
2060
2261
|
})
|
|
2061
2262
|
);
|
|
2062
|
-
return
|
|
2263
|
+
return enriched;
|
|
2063
2264
|
}
|
|
2064
2265
|
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
2065
2266
|
return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
|
|
@@ -2069,20 +2270,32 @@ async function actorAutoPublishes(nestId, userEmail) {
|
|
|
2069
2270
|
const roles = await resolveUserRoles(nestId, userEmail);
|
|
2070
2271
|
return roles.includes("owner") || roles.includes("admin");
|
|
2071
2272
|
}
|
|
2273
|
+
async function assertMayFlagPrime(nestId, userEmail) {
|
|
2274
|
+
const roles = await resolveUserRoles(nestId, userEmail);
|
|
2275
|
+
if (!roles.includes("owner") && !roles.includes("admin")) {
|
|
2276
|
+
throw new ForbiddenError(
|
|
2277
|
+
"Only the nest owner or an admin can change a document's prime flag."
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
async function autoPublishReason(nestId, userEmail, doc) {
|
|
2282
|
+
if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
|
|
2283
|
+
if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
|
|
2284
|
+
if (await nestReviewsPrimeOnly(nestId)) {
|
|
2285
|
+
return "prime-only review, document is not prime";
|
|
2286
|
+
}
|
|
2287
|
+
return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
|
|
2288
|
+
}
|
|
2072
2289
|
async function createNode(nestId, input, userEmail) {
|
|
2073
2290
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2074
|
-
|
|
2291
|
+
assertTitleLength(input.title);
|
|
2292
|
+
if (!input.id) assertTitleSluggable(input.title);
|
|
2293
|
+
const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
|
|
2075
2294
|
let id = input.id;
|
|
2076
2295
|
if (!id) {
|
|
2077
2296
|
const folderSegments = folderToSegments(input.folder ?? "");
|
|
2078
|
-
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
2079
|
-
throw new ValidationError(
|
|
2080
|
-
"each folder or title segment must be at most 100 characters"
|
|
2081
|
-
);
|
|
2082
|
-
}
|
|
2083
2297
|
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
2084
2298
|
}
|
|
2085
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2086
2299
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
2087
2300
|
const requestedType = input.type || "document";
|
|
2088
2301
|
if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
|
|
@@ -2098,72 +2311,104 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2098
2311
|
)})`
|
|
2099
2312
|
);
|
|
2100
2313
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
tags.push(deptTag);
|
|
2104
|
-
}
|
|
2105
|
-
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2106
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2107
|
-
const initialStatus = autoPublish ? "published" : "draft";
|
|
2108
|
-
const initialVersion = autoPublish ? 0 : 1;
|
|
2109
|
-
let node = {
|
|
2110
|
-
id,
|
|
2111
|
-
filePath: "",
|
|
2112
|
-
frontmatter: {
|
|
2113
|
-
title: input.title,
|
|
2114
|
-
type: requestedType,
|
|
2115
|
-
tags,
|
|
2116
|
-
status: input.status || initialStatus,
|
|
2117
|
-
version: initialVersion,
|
|
2118
|
-
created_at: now,
|
|
2119
|
-
updated_at: now,
|
|
2120
|
-
metadata: {
|
|
2121
|
-
owners: ["*"],
|
|
2122
|
-
scope: input.scope || "team",
|
|
2123
|
-
...input.schedule ? { schedule: input.schedule } : {}
|
|
2124
|
-
}
|
|
2125
|
-
},
|
|
2126
|
-
body: input.content,
|
|
2127
|
-
rawContent: ""
|
|
2128
|
-
};
|
|
2129
|
-
await storage.writeDocument(id, serializeDocument(node));
|
|
2130
|
-
await syncNodeTags(nestId, id, tags);
|
|
2131
|
-
let savedVersion = 1;
|
|
2132
|
-
if (!autoPublish) {
|
|
2314
|
+
return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
|
|
2315
|
+
let occupied = false;
|
|
2133
2316
|
try {
|
|
2134
|
-
await
|
|
2317
|
+
await storage.readDocument(id);
|
|
2318
|
+
occupied = true;
|
|
2135
2319
|
} catch (err) {
|
|
2136
|
-
|
|
2320
|
+
if (!(err instanceof DocumentNotFoundError)) throw err;
|
|
2137
2321
|
}
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2322
|
+
if (occupied) {
|
|
2323
|
+
throw new ConflictError(
|
|
2324
|
+
`A document already exists at ${id}. Update it, or create this one under a different folder or title.`
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
const db = getDb();
|
|
2328
|
+
const orphan = await db.get(
|
|
2329
|
+
"SELECT 1 AS present FROM node_versions WHERE nest_id = ? AND node_id = ? LIMIT 1",
|
|
2330
|
+
[nestId, id]
|
|
2331
|
+
);
|
|
2332
|
+
if (orphan) {
|
|
2333
|
+
console.warn(
|
|
2334
|
+
`[nodes] clearing orphaned rows for ${id} \u2014 version history exists but no document does`
|
|
2335
|
+
);
|
|
2336
|
+
await db.transaction(async (tx) => {
|
|
2337
|
+
await purgeNodeKeyedRows(tx, nestId, id);
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
const deptTag = await departmentTagFor(userEmail);
|
|
2341
|
+
if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
|
|
2342
|
+
tags.push(deptTag);
|
|
2343
|
+
}
|
|
2344
|
+
if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
|
|
2345
|
+
const metadata = {
|
|
2346
|
+
owners: ["*"],
|
|
2347
|
+
scope: input.scope || "team",
|
|
2348
|
+
...input.schedule ? { schedule: input.schedule } : {},
|
|
2349
|
+
...input.prime === void 0 ? {} : { prime: input.prime }
|
|
2350
|
+
};
|
|
2351
|
+
const publishReason = await autoPublishReason(nestId, userEmail, {
|
|
2352
|
+
tags,
|
|
2353
|
+
metadata
|
|
2146
2354
|
});
|
|
2147
|
-
|
|
2355
|
+
const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
|
|
2356
|
+
let published = publishReason !== null;
|
|
2148
2357
|
try {
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2358
|
+
await engineApi.run(
|
|
2359
|
+
"context_create",
|
|
2360
|
+
{
|
|
2361
|
+
id,
|
|
2362
|
+
title: input.title,
|
|
2363
|
+
content: input.content,
|
|
2364
|
+
type: requestedType,
|
|
2365
|
+
// A skill node is invalid without a skill block, and this surface has
|
|
2366
|
+
// no field for one — default the trigger from the title, same as the
|
|
2367
|
+
// CLI and mcp-server do.
|
|
2368
|
+
...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
|
|
2369
|
+
...tags.length ? { tags } : {},
|
|
2370
|
+
...input.status ? { status: input.status } : {},
|
|
2371
|
+
metadata,
|
|
2372
|
+
publish: published,
|
|
2373
|
+
...publishNote ? { note: publishNote } : {}
|
|
2374
|
+
},
|
|
2375
|
+
await opContext(nestId, userEmail)
|
|
2376
|
+
);
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
let onDisk = true;
|
|
2379
|
+
try {
|
|
2380
|
+
await storage.readDocument(id);
|
|
2381
|
+
} catch {
|
|
2382
|
+
onDisk = false;
|
|
2383
|
+
}
|
|
2384
|
+
if (!onDisk) throw err;
|
|
2385
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
2386
|
+
published = false;
|
|
2387
|
+
}
|
|
2388
|
+
const node = await storage.readDocument(id);
|
|
2389
|
+
await syncNodeTags(nestId, id, tags);
|
|
2390
|
+
let savedVersion = 1;
|
|
2391
|
+
if (published) {
|
|
2392
|
+
savedVersion = node.frontmatter.version || 1;
|
|
2154
2393
|
await createVersion({
|
|
2155
2394
|
nestId,
|
|
2156
2395
|
nodeId: id,
|
|
2157
2396
|
version: savedVersion,
|
|
2158
|
-
content:
|
|
2397
|
+
content: node.body || "",
|
|
2159
2398
|
author: userEmail,
|
|
2160
2399
|
status: "published",
|
|
2161
|
-
tags
|
|
2400
|
+
tags,
|
|
2401
|
+
changeNote: publishNote
|
|
2162
2402
|
});
|
|
2163
2403
|
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2404
|
+
} else {
|
|
2405
|
+
if (publishReason === null) {
|
|
2406
|
+
try {
|
|
2407
|
+
await versionManager.createVersion(node, userEmail);
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2167
2412
|
await createVersion({
|
|
2168
2413
|
nestId,
|
|
2169
2414
|
nodeId: id,
|
|
@@ -2174,140 +2419,102 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2174
2419
|
tags
|
|
2175
2420
|
});
|
|
2176
2421
|
}
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
}
|
|
2181
|
-
function explicitFrontmatterStatus(raw) {
|
|
2182
|
-
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
2183
|
-
if (!fm) return null;
|
|
2184
|
-
const m = fm[1].match(/^status:[ \t]*["']?([A-Za-z_-]+)["']?[ \t]*$/m);
|
|
2185
|
-
return m ? normalizeStatus(m[1]) : null;
|
|
2422
|
+
await trackEvent("node.create", { nestId, nodeId: id });
|
|
2423
|
+
return { node, version: savedVersion };
|
|
2424
|
+
});
|
|
2186
2425
|
}
|
|
2187
|
-
async function
|
|
2188
|
-
const
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2426
|
+
async function stageImportedFiles(nestId, files, userEmail) {
|
|
2427
|
+
const carried = files.filter(
|
|
2428
|
+
(f) => !String(f?.path || "").split(/[\\/]/).includes("_suggestions")
|
|
2429
|
+
);
|
|
2430
|
+
if (!carried.length) return { written: 0, rejected: [] };
|
|
2431
|
+
const result = await engineApi.run(
|
|
2432
|
+
"context_import",
|
|
2433
|
+
{ files: carried, publish: false },
|
|
2434
|
+
await opContext(nestId, userEmail)
|
|
2193
2435
|
);
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2436
|
+
const rejected = (result.failed ?? []).map((f) => f.id ?? "(unnamed)");
|
|
2437
|
+
if (rejected.length) {
|
|
2438
|
+
console.warn(
|
|
2439
|
+
`[import] ${rejected.length} file(s) rejected:`,
|
|
2440
|
+
(result.failed ?? []).map((f) => `${f.id}: ${f.error}`).join(", ")
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
return { written: result.written ?? 0, rejected };
|
|
2200
2444
|
}
|
|
2201
|
-
async function
|
|
2202
|
-
const { storage } = await engineCache.get(nestId);
|
|
2203
|
-
let docs;
|
|
2445
|
+
async function alignOnDiskStatus(nestId, documentId, status) {
|
|
2204
2446
|
try {
|
|
2205
|
-
|
|
2447
|
+
const { storage } = await engineCache.get(nestId);
|
|
2448
|
+
const node = await storage.readDocument(documentId);
|
|
2449
|
+
if (node.frontmatter.status === status) return;
|
|
2450
|
+
await storage.writeDocument(
|
|
2451
|
+
documentId,
|
|
2452
|
+
serializeNodeSafe({
|
|
2453
|
+
...node,
|
|
2454
|
+
frontmatter: { ...node.frontmatter, status }
|
|
2455
|
+
})
|
|
2456
|
+
);
|
|
2206
2457
|
} catch (err) {
|
|
2207
|
-
console.
|
|
2208
|
-
|
|
2458
|
+
console.warn(
|
|
2459
|
+
`[import] could not align on-disk status for ${nestId}/${documentId}:`,
|
|
2460
|
+
err
|
|
2461
|
+
);
|
|
2209
2462
|
}
|
|
2463
|
+
}
|
|
2464
|
+
async function registerImportedDocuments(nestId, userEmail) {
|
|
2210
2465
|
const startedAt = Date.now();
|
|
2211
|
-
|
|
2212
|
-
await
|
|
2213
|
-
let
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2466
|
+
const tracked = await getTrackedNodeIds(nestId);
|
|
2467
|
+
(await engineCache.get(nestId)).invalidateDiscovery();
|
|
2468
|
+
let result;
|
|
2469
|
+
try {
|
|
2470
|
+
result = await engineApi.run(
|
|
2471
|
+
"context_import",
|
|
2472
|
+
{ discover: true, author: userEmail, exclude_ids: [...tracked] },
|
|
2473
|
+
await opContext(nestId, userEmail)
|
|
2474
|
+
);
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
console.error("[import] register failed", nestId, err);
|
|
2477
|
+
throw new Error(
|
|
2478
|
+
`Failed to import documents: ${err?.message ?? String(err)}`
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
const docs = result.documents ?? [];
|
|
2482
|
+
const engineMs = Date.now() - startedAt;
|
|
2483
|
+
const dbStartedAt = Date.now();
|
|
2484
|
+
for (const d of docs) {
|
|
2485
|
+
const tags = d.tags.map((t) => normalizeTag2(String(t)));
|
|
2486
|
+
await createVersion({
|
|
2487
|
+
nestId,
|
|
2488
|
+
nodeId: d.id,
|
|
2489
|
+
version: d.version,
|
|
2490
|
+
content: d.content,
|
|
2491
|
+
author: userEmail,
|
|
2492
|
+
status: d.status,
|
|
2493
|
+
changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
|
|
2494
|
+
tags
|
|
2495
|
+
});
|
|
2496
|
+
if (d.status === "published") {
|
|
2497
|
+
await setApprovedVersion(nestId, d.id, d.version, userEmail);
|
|
2498
|
+
} else {
|
|
2499
|
+
await alignOnDiskStatus(nestId, d.id, d.status);
|
|
2225
2500
|
}
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
version,
|
|
2244
|
-
content,
|
|
2245
|
-
author: userEmail,
|
|
2246
|
-
status: "draft",
|
|
2247
|
-
changeNote: "Imported from existing folder (kept unpublished)",
|
|
2248
|
-
tags
|
|
2249
|
-
});
|
|
2250
|
-
await syncNodeTags(nestId, nodeId, tags);
|
|
2251
|
-
keptUnpublished++;
|
|
2252
|
-
registered++;
|
|
2253
|
-
continue;
|
|
2254
|
-
}
|
|
2255
|
-
let published = false;
|
|
2256
|
-
try {
|
|
2257
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
2258
|
-
editedBy: userEmail,
|
|
2259
|
-
note: "Imported from existing folder"
|
|
2260
|
-
});
|
|
2261
|
-
version = result.node.frontmatter.version || version;
|
|
2262
|
-
content = result.node.body || content;
|
|
2263
|
-
published = true;
|
|
2264
|
-
} catch (err) {
|
|
2265
|
-
console.warn(
|
|
2266
|
-
`[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
|
|
2267
|
-
);
|
|
2268
|
-
try {
|
|
2269
|
-
await resetImportedDocForPublish(storage, nestId, nodeId);
|
|
2270
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
2271
|
-
editedBy: userEmail,
|
|
2272
|
-
note: "Imported from existing folder (reset chain)"
|
|
2273
|
-
});
|
|
2274
|
-
version = result.node.frontmatter.version || 1;
|
|
2275
|
-
content = result.node.body || content;
|
|
2276
|
-
published = true;
|
|
2277
|
-
publishRecovered++;
|
|
2278
|
-
} catch (err2) {
|
|
2279
|
-
publishFailed++;
|
|
2280
|
-
console.error(
|
|
2281
|
-
"reset+republish failed (import register)",
|
|
2282
|
-
nodeId,
|
|
2283
|
-
err2
|
|
2284
|
-
);
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
await createVersion({
|
|
2288
|
-
nestId,
|
|
2289
|
-
nodeId,
|
|
2290
|
-
version,
|
|
2291
|
-
content,
|
|
2292
|
-
author: userEmail,
|
|
2293
|
-
status: published ? "published" : "draft",
|
|
2294
|
-
changeNote: "Imported from existing folder",
|
|
2295
|
-
tags
|
|
2296
|
-
});
|
|
2297
|
-
if (published) {
|
|
2298
|
-
await setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
2299
|
-
}
|
|
2300
|
-
await syncNodeTags(nestId, nodeId, tags);
|
|
2301
|
-
registered++;
|
|
2302
|
-
}
|
|
2303
|
-
console.log(
|
|
2304
|
-
`[import] register done: registered=${registered} alreadyTracked=${alreadyTracked} keptUnpublished=${keptUnpublished} publishRecovered=${publishRecovered} publishFailed=${publishFailed} tookMs=${Date.now() - startedAt}`
|
|
2305
|
-
);
|
|
2306
|
-
await trackEvent("nest.import.documents", { nestId, registered });
|
|
2307
|
-
return registered;
|
|
2308
|
-
}
|
|
2309
|
-
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
2310
|
-
return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
|
|
2501
|
+
await syncNodeTags(nestId, d.id, tags);
|
|
2502
|
+
}
|
|
2503
|
+
const published = docs.filter((d) => d.status === "published").length;
|
|
2504
|
+
console.log(
|
|
2505
|
+
`[import] register done: registered=${docs.length} published=${published} kept=${docs.length - published} failed=${result.failed.length} alreadyTracked=${tracked.size} engineMs=${engineMs} dbMs=${Date.now() - dbStartedAt}`
|
|
2506
|
+
);
|
|
2507
|
+
if (result.failed.length) {
|
|
2508
|
+
console.warn(
|
|
2509
|
+
"[import] documents that would not publish:",
|
|
2510
|
+
result.failed.map((f) => `${f.id}: ${f.error}`).join(", ")
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
await trackEvent("nest.import.documents", { nestId, registered: docs.length });
|
|
2514
|
+
return docs.length;
|
|
2515
|
+
}
|
|
2516
|
+
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
2517
|
+
return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
|
|
2311
2518
|
try {
|
|
2312
2519
|
const { storage, versions: versionManager } = await engineCache.get(
|
|
2313
2520
|
nestId
|
|
@@ -2318,30 +2525,43 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2318
2525
|
} catch {
|
|
2319
2526
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2320
2527
|
}
|
|
2528
|
+
const opInput = {};
|
|
2321
2529
|
if (patch.content !== void 0) {
|
|
2322
2530
|
node = { ...node, body: patch.content };
|
|
2531
|
+
opInput.content = patch.content;
|
|
2323
2532
|
}
|
|
2324
2533
|
if (patch.append) {
|
|
2325
2534
|
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
2535
|
+
opInput.append = "\n" + patch.append;
|
|
2326
2536
|
}
|
|
2327
2537
|
if (patch.tags) {
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
];
|
|
2332
|
-
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
2538
|
+
const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
|
|
2539
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
|
|
2540
|
+
opInput.tags = nextTags;
|
|
2333
2541
|
}
|
|
2334
2542
|
if (patch.status) {
|
|
2335
2543
|
node = {
|
|
2336
2544
|
...node,
|
|
2337
2545
|
frontmatter: { ...node.frontmatter, status: patch.status }
|
|
2338
2546
|
};
|
|
2547
|
+
opInput.status = normalizeStatus(patch.status);
|
|
2548
|
+
} else if (isRejected(node)) {
|
|
2549
|
+
node = {
|
|
2550
|
+
...node,
|
|
2551
|
+
frontmatter: { ...node.frontmatter, status: "draft" }
|
|
2552
|
+
};
|
|
2553
|
+
opInput.status = "draft";
|
|
2339
2554
|
}
|
|
2340
2555
|
if (patch.title) {
|
|
2556
|
+
if (patch.title !== node.frontmatter.title) {
|
|
2557
|
+
assertTitleLength(patch.title);
|
|
2558
|
+
assertTitleSluggable(patch.title);
|
|
2559
|
+
}
|
|
2341
2560
|
node = {
|
|
2342
2561
|
...node,
|
|
2343
2562
|
frontmatter: { ...node.frontmatter, title: patch.title }
|
|
2344
2563
|
};
|
|
2564
|
+
opInput.title = patch.title;
|
|
2345
2565
|
}
|
|
2346
2566
|
if (patch.schedule !== void 0) {
|
|
2347
2567
|
if (!isRunnableType(node.frontmatter.type)) {
|
|
@@ -2355,10 +2575,25 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2355
2575
|
if (patch.schedule === "") delete metadata.schedule;
|
|
2356
2576
|
else metadata.schedule = patch.schedule;
|
|
2357
2577
|
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2578
|
+
opInput.metadata = {
|
|
2579
|
+
...opInput.metadata,
|
|
2580
|
+
schedule: patch.schedule === "" ? null : patch.schedule
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
if (patch.prime !== void 0) {
|
|
2584
|
+
await assertMayFlagPrime(nestId, userEmail);
|
|
2585
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
2586
|
+
if (patch.prime === null) delete metadata.prime;
|
|
2587
|
+
else metadata.prime = patch.prime;
|
|
2588
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2589
|
+
opInput.metadata = { ...opInput.metadata, prime: patch.prime };
|
|
2358
2590
|
}
|
|
2359
2591
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2360
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2361
2592
|
const currentTags = node.frontmatter.tags || [];
|
|
2593
|
+
const publishReason = await autoPublishReason(nestId, userEmail, {
|
|
2594
|
+
tags: currentTags,
|
|
2595
|
+
metadata: node.frontmatter.metadata
|
|
2596
|
+
});
|
|
2362
2597
|
const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
|
|
2363
2598
|
const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
|
|
2364
2599
|
if (pendingReview && !isAuthorEditingOwnReview) {
|
|
@@ -2367,22 +2602,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2367
2602
|
);
|
|
2368
2603
|
}
|
|
2369
2604
|
let responseVersion;
|
|
2370
|
-
if (
|
|
2605
|
+
if (publishReason === null || isAuthorEditingOwnReview) {
|
|
2371
2606
|
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
2372
|
-
const newVersion =
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
// Drop stale published-state checksum so the next verified read
|
|
2380
|
-
// doesn't flag this write as external drift.
|
|
2381
|
-
checksum: void 0
|
|
2382
|
-
}
|
|
2383
|
-
};
|
|
2384
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
2385
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2607
|
+
const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
|
|
2608
|
+
await engineApi.run(
|
|
2609
|
+
"context_update",
|
|
2610
|
+
{ id: nodeId, ...opInput, publish: false, version: newVersion },
|
|
2611
|
+
await opContext(nestId, userEmail)
|
|
2612
|
+
);
|
|
2613
|
+
node = await storage.readDocument(nodeId);
|
|
2386
2614
|
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2387
2615
|
try {
|
|
2388
2616
|
await versionManager.createVersion(node, userEmail, {
|
|
@@ -2410,25 +2638,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2410
2638
|
}
|
|
2411
2639
|
responseVersion = newVersion;
|
|
2412
2640
|
} else {
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
frontmatter: {
|
|
2416
|
-
...node.frontmatter,
|
|
2417
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2418
|
-
checksum: void 0
|
|
2419
|
-
}
|
|
2420
|
-
};
|
|
2421
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
2422
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2423
|
-
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2424
|
-
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2641
|
+
const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
|
|
2642
|
+
let publishedVersion;
|
|
2425
2643
|
try {
|
|
2426
|
-
const result = await
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2644
|
+
const result = await engineApi.run(
|
|
2645
|
+
"context_update",
|
|
2646
|
+
{ id: nodeId, ...opInput, publish: true, note: publishNote },
|
|
2647
|
+
await opContext(nestId, userEmail)
|
|
2648
|
+
);
|
|
2649
|
+
publishedVersion = result.version;
|
|
2432
2650
|
} catch (err) {
|
|
2433
2651
|
console.error(
|
|
2434
2652
|
"publishDocument failed (node patch auto-publish)",
|
|
@@ -2436,6 +2654,8 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2436
2654
|
);
|
|
2437
2655
|
throw err;
|
|
2438
2656
|
}
|
|
2657
|
+
node = await storage.readDocument(nodeId);
|
|
2658
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2439
2659
|
await upsertVersion({
|
|
2440
2660
|
nestId,
|
|
2441
2661
|
nodeId,
|
|
@@ -2444,7 +2664,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2444
2664
|
author: userEmail,
|
|
2445
2665
|
status: "published",
|
|
2446
2666
|
tags: currentTags,
|
|
2447
|
-
changeNote:
|
|
2667
|
+
changeNote: publishNote
|
|
2448
2668
|
});
|
|
2449
2669
|
await setApprovedVersion(
|
|
2450
2670
|
nestId,
|
|
@@ -2464,9 +2684,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2464
2684
|
}
|
|
2465
2685
|
});
|
|
2466
2686
|
}
|
|
2687
|
+
async function assertNotReviewLocked(nestId, nodeId, userEmail, action) {
|
|
2688
|
+
if (!await isStewardshipEnabled(nestId)) return;
|
|
2689
|
+
if (!await getPendingReview(nestId, nodeId)) return;
|
|
2690
|
+
if (canApproveWith(await resolveUserRoles(nestId, userEmail, { nodeId }))) return;
|
|
2691
|
+
throw new LockedError(
|
|
2692
|
+
`This document is awaiting steward review and is locked. Approve or reject the pending review before ${action}, or ask a reviewer to.`
|
|
2693
|
+
);
|
|
2694
|
+
}
|
|
2467
2695
|
function derivedIdOf(sourceNodeId) {
|
|
2468
2696
|
return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
|
|
2469
2697
|
}
|
|
2698
|
+
var NODE_KEYED_TABLES = [
|
|
2699
|
+
["node_versions", "node_id"],
|
|
2700
|
+
["review_requests", "node_id"],
|
|
2701
|
+
["deletion_requests", "node_id"],
|
|
2702
|
+
["approved_versions", "node_id"],
|
|
2703
|
+
["annotation_threads", "node_id"],
|
|
2704
|
+
["comments", "node_id"],
|
|
2705
|
+
["node_tag_index", "node_id"],
|
|
2706
|
+
["watchers", "node_id"],
|
|
2707
|
+
["schedules", "agent_node"],
|
|
2708
|
+
// A live capability token bound to one agent node — deleting the agent must
|
|
2709
|
+
// revoke it, or a later node at that id answers the old webhook.
|
|
2710
|
+
["trigger_hooks", "agent_node"],
|
|
2711
|
+
["edges", "from_node"],
|
|
2712
|
+
["edges", "to_node"]
|
|
2713
|
+
];
|
|
2714
|
+
async function purgeNodeKeyedRows(tx, nestId, nodeId) {
|
|
2715
|
+
for (const [table, column] of NODE_KEYED_TABLES) {
|
|
2716
|
+
await tx.run(`DELETE FROM ${table} WHERE nest_id = ? AND ${column} = ?`, [
|
|
2717
|
+
nestId,
|
|
2718
|
+
nodeId
|
|
2719
|
+
]);
|
|
2720
|
+
}
|
|
2721
|
+
await tx.run(
|
|
2722
|
+
`DELETE FROM stewards
|
|
2723
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2724
|
+
[nestId, nodeId]
|
|
2725
|
+
);
|
|
2726
|
+
await tx.run(
|
|
2727
|
+
`DELETE FROM grants
|
|
2728
|
+
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2729
|
+
[nestId, nodeId]
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
async function rewriteNodeKeyedRows(tx, nestId, oldId, newId) {
|
|
2733
|
+
for (const [table, column] of NODE_KEYED_TABLES) {
|
|
2734
|
+
await tx.run(
|
|
2735
|
+
`UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
|
|
2736
|
+
[newId, nestId, oldId]
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
await tx.run(
|
|
2740
|
+
`UPDATE stewards SET node_pattern = ?
|
|
2741
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2742
|
+
[newId, nestId, oldId]
|
|
2743
|
+
);
|
|
2744
|
+
await tx.run(
|
|
2745
|
+
`UPDATE grants SET target = ?
|
|
2746
|
+
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2747
|
+
[newId, nestId, oldId]
|
|
2748
|
+
);
|
|
2749
|
+
}
|
|
2470
2750
|
async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
2471
2751
|
const { storage } = await engineCache.get(nestId);
|
|
2472
2752
|
let node;
|
|
@@ -2475,108 +2755,138 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
|
2475
2755
|
} catch {
|
|
2476
2756
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2477
2757
|
}
|
|
2478
|
-
|
|
2479
|
-
throw new LockedError(
|
|
2480
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
|
|
2481
|
-
);
|
|
2482
|
-
}
|
|
2758
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
|
|
2483
2759
|
const leaf = nodeId.split("/").pop();
|
|
2484
2760
|
const segments = folderToSegments(folder);
|
|
2485
2761
|
const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
|
|
2486
2762
|
if (newId === nodeId) return { node, previousId: nodeId };
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
...node,
|
|
2497
|
-
id: newId,
|
|
2498
|
-
frontmatter: {
|
|
2499
|
-
...node.frontmatter,
|
|
2500
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2763
|
+
return withNodeWriteLock(nodeWriteKey(nestId, newId), async () => {
|
|
2764
|
+
try {
|
|
2765
|
+
await storage.readDocument(newId);
|
|
2766
|
+
throw new ConflictError(
|
|
2767
|
+
`A document already exists at that location: ${newId}`
|
|
2768
|
+
);
|
|
2769
|
+
} catch (err) {
|
|
2770
|
+
if (err instanceof ConflictError) throw err;
|
|
2771
|
+
if (!(err instanceof DocumentNotFoundError)) throw err;
|
|
2501
2772
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2773
|
+
const moved = {
|
|
2774
|
+
...node,
|
|
2775
|
+
id: newId,
|
|
2776
|
+
frontmatter: {
|
|
2777
|
+
...node.frontmatter,
|
|
2778
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
await storage.writeDocument(newId, serializeNodeSafe(moved));
|
|
2782
|
+
try {
|
|
2783
|
+
const nestPath = resolveNestPath(nestId);
|
|
2784
|
+
const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
|
|
2785
|
+
const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
|
|
2786
|
+
if (oldChain !== newChain) {
|
|
2787
|
+
await mkdir(dirname(newChain), { recursive: true });
|
|
2788
|
+
await rename(oldChain, newChain);
|
|
2789
|
+
}
|
|
2790
|
+
} catch {
|
|
2511
2791
|
}
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
for (const [table, column] of [
|
|
2520
|
-
["node_versions", "node_id"],
|
|
2521
|
-
["review_requests", "node_id"],
|
|
2522
|
-
["approved_versions", "node_id"],
|
|
2523
|
-
["annotation_threads", "node_id"],
|
|
2524
|
-
["comments", "node_id"],
|
|
2525
|
-
["node_tag_index", "node_id"]
|
|
2526
|
-
]) {
|
|
2792
|
+
await storage.deleteDocument(nodeId);
|
|
2793
|
+
const oldDerived = derivedIdOf(nodeId);
|
|
2794
|
+
const newDerived = derivedIdOf(newId);
|
|
2795
|
+
const db = getDb();
|
|
2796
|
+
await db.transaction(async (tx) => {
|
|
2797
|
+
await rewriteNodeKeyedRows(tx, nestId, nodeId, newId);
|
|
2798
|
+
await rewriteNodeKeyedRows(tx, nestId, oldDerived, newDerived);
|
|
2527
2799
|
await tx.run(
|
|
2528
|
-
|
|
2800
|
+
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
2529
2801
|
[newId, nestId, nodeId]
|
|
2530
2802
|
);
|
|
2531
2803
|
await tx.run(
|
|
2532
|
-
`UPDATE
|
|
2533
|
-
|
|
2804
|
+
`UPDATE run_steps SET node_id = ?
|
|
2805
|
+
WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
|
|
2806
|
+
[newId, nodeId, nestId]
|
|
2807
|
+
);
|
|
2808
|
+
});
|
|
2809
|
+
try {
|
|
2810
|
+
const derivedDoc = await storage.readDocument(oldDerived);
|
|
2811
|
+
await storage.writeDocument(
|
|
2812
|
+
newDerived,
|
|
2813
|
+
serializeNodeSafe({ ...derivedDoc, id: newDerived })
|
|
2534
2814
|
);
|
|
2815
|
+
await storage.deleteDocument(oldDerived);
|
|
2816
|
+
} catch {
|
|
2535
2817
|
}
|
|
2536
|
-
await
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
[newId, nestId, nodeId]
|
|
2549
|
-
);
|
|
2550
|
-
await tx.run(
|
|
2551
|
-
"UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
|
|
2552
|
-
[newId, nestId, nodeId]
|
|
2818
|
+
await trackEvent("node.move", { nestId, nodeId, newId });
|
|
2819
|
+
return { node: moved, previousId: nodeId };
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
async function deleteNode(nestId, nodeId, userEmail) {
|
|
2823
|
+
const { storage } = await engineCache.get(nestId);
|
|
2824
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
|
|
2825
|
+
try {
|
|
2826
|
+
await engineApi.run(
|
|
2827
|
+
"context_delete",
|
|
2828
|
+
{ id: nodeId },
|
|
2829
|
+
await opContext(nestId, userEmail)
|
|
2553
2830
|
);
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2831
|
+
} catch {
|
|
2832
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2833
|
+
}
|
|
2834
|
+
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2835
|
+
const derivedId = derivedIdOf(nodeId);
|
|
2836
|
+
try {
|
|
2837
|
+
await engineApi.run(
|
|
2838
|
+
"context_delete",
|
|
2839
|
+
{ id: derivedId },
|
|
2840
|
+
await opContext(nestId, userEmail)
|
|
2557
2841
|
);
|
|
2842
|
+
await removeNodeFromTagIndex(nestId, derivedId);
|
|
2843
|
+
} catch {
|
|
2844
|
+
}
|
|
2845
|
+
const db = getDb();
|
|
2846
|
+
await db.transaction(async (tx) => {
|
|
2847
|
+
await purgeNodeKeyedRows(tx, nestId, nodeId);
|
|
2848
|
+
await purgeNodeKeyedRows(tx, nestId, derivedId);
|
|
2558
2849
|
await tx.run(
|
|
2559
|
-
`
|
|
2560
|
-
|
|
2561
|
-
|
|
2850
|
+
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
2851
|
+
VALUES (?, ?, ?, ?)
|
|
2852
|
+
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
2853
|
+
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
2854
|
+
[nestId, nodeId, userEmail, (/* @__PURE__ */ new Date()).toISOString()]
|
|
2562
2855
|
);
|
|
2563
2856
|
});
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
);
|
|
2570
|
-
|
|
2571
|
-
|
|
2857
|
+
await trackEvent("node.delete", { nestId, nodeId });
|
|
2858
|
+
}
|
|
2859
|
+
async function deleteFolder(nestId, folder, userEmail) {
|
|
2860
|
+
const segments = folderToSegments(folder);
|
|
2861
|
+
if (segments.length === 0) {
|
|
2862
|
+
throw new ValidationError("folder is required");
|
|
2863
|
+
}
|
|
2864
|
+
const rel = segments.join("/");
|
|
2865
|
+
const { storage } = await engineCache.get(nestId);
|
|
2866
|
+
const docs = await storage.discoverDocuments();
|
|
2867
|
+
const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
|
|
2868
|
+
if (targets.length === 0) {
|
|
2869
|
+
throw new NotFoundError(`No documents found under folder: ${folder}`);
|
|
2870
|
+
}
|
|
2871
|
+
const deleted = [];
|
|
2872
|
+
for (const id of targets) {
|
|
2873
|
+
try {
|
|
2874
|
+
await deleteNode(nestId, id, userEmail);
|
|
2875
|
+
deleted.push(id);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
const detail = deleted.length > 0 ? ` (${deleted.length} of ${targets.length} already deleted)` : "";
|
|
2878
|
+
throw new Error(
|
|
2879
|
+
`Failed to delete "${id}"${detail}: ${err instanceof Error ? err.message : String(err)}`
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2572
2882
|
}
|
|
2573
|
-
|
|
2574
|
-
return { node: moved, previousId: nodeId };
|
|
2883
|
+
return deleted;
|
|
2575
2884
|
}
|
|
2576
2885
|
|
|
2577
2886
|
// src/nests/unsynced-service.ts
|
|
2578
|
-
import { readdirSync, readFileSync,
|
|
2887
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
2579
2888
|
import { join as join3, relative, resolve } from "path";
|
|
2889
|
+
import { rm } from "fs/promises";
|
|
2580
2890
|
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
2581
2891
|
function isNestStorageRoot(absPath) {
|
|
2582
2892
|
return resolve(absPath) === resolve(nestStorageRoot());
|
|
@@ -2744,23 +3054,31 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
2744
3054
|
`[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
|
|
2745
3055
|
);
|
|
2746
3056
|
}
|
|
2747
|
-
const nest = await importNest(userId, nestName2
|
|
3057
|
+
const nest = await importNest(userId, nestName2);
|
|
2748
3058
|
console.log(
|
|
2749
3059
|
`[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
|
|
2750
3060
|
);
|
|
2751
|
-
|
|
3061
|
+
let documents;
|
|
3062
|
+
try {
|
|
3063
|
+
await stageImportedFiles(nest.id, files, callerEmail);
|
|
3064
|
+
documents = await registerImportedDocuments(nest.id, callerEmail);
|
|
3065
|
+
} catch (err) {
|
|
3066
|
+
await deleteNest(nest.id).catch(() => {
|
|
3067
|
+
});
|
|
3068
|
+
throw err;
|
|
3069
|
+
}
|
|
2752
3070
|
console.log(
|
|
2753
3071
|
`[unsynced] registered ${documents} document(s) for nest ${nest.id}`
|
|
2754
3072
|
);
|
|
2755
3073
|
try {
|
|
2756
|
-
|
|
3074
|
+
await rm(src, { recursive: true, force: true });
|
|
2757
3075
|
console.log(`[unsynced] removed source folder ${src}`);
|
|
2758
3076
|
} catch (err) {
|
|
2759
3077
|
console.error("[unsynced] failed to remove source folder", src, err);
|
|
2760
3078
|
}
|
|
2761
3079
|
return { nest, documents };
|
|
2762
3080
|
}
|
|
2763
|
-
function deleteUnsyncedFolder(folderName) {
|
|
3081
|
+
async function deleteUnsyncedFolder(folderName) {
|
|
2764
3082
|
console.log(`[unsynced] delete requested folder="${folderName}"`);
|
|
2765
3083
|
assertSafeFolderName(folderName);
|
|
2766
3084
|
const src = join3(config.DATA_ROOT, folderName);
|
|
@@ -2776,7 +3094,7 @@ function deleteUnsyncedFolder(folderName) {
|
|
|
2776
3094
|
if (!stat.isDirectory()) {
|
|
2777
3095
|
throw new ValidationError(`Not a directory: ${folderName}`);
|
|
2778
3096
|
}
|
|
2779
|
-
|
|
3097
|
+
await rm(src, { recursive: true, force: true });
|
|
2780
3098
|
console.log(`[unsynced] deleted folder ${src}`);
|
|
2781
3099
|
}
|
|
2782
3100
|
|
|
@@ -2883,42 +3201,50 @@ nestRoutes.post("/:nestId", async (c) => {
|
|
|
2883
3201
|
throw new NotFoundError("Not found");
|
|
2884
3202
|
}
|
|
2885
3203
|
const body = await c.req.json();
|
|
3204
|
+
const files = Array.isArray(body.files) ? body.files : [];
|
|
3205
|
+
const userId = c.get("userId");
|
|
3206
|
+
if (body.nestId) {
|
|
3207
|
+
const target = body.nestId;
|
|
3208
|
+
const nest2 = await getNest(target);
|
|
3209
|
+
if (!nest2 || !nest2.is_imported || await effectivePermission(target, userId) !== "owner") {
|
|
3210
|
+
throw new NotFoundError("Nest not found");
|
|
3211
|
+
}
|
|
3212
|
+
const email2 = await resolveCallerEmail(userId);
|
|
3213
|
+
if (body.finalize) {
|
|
3214
|
+
return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
|
|
3215
|
+
}
|
|
3216
|
+
const registered = await getDb().get(
|
|
3217
|
+
"SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
|
|
3218
|
+
[target]
|
|
3219
|
+
);
|
|
3220
|
+
if (Number(registered?.c ?? 0) > 0) {
|
|
3221
|
+
throw new ValidationError(
|
|
3222
|
+
"This nest has already been imported. Start a new import instead of adding files to a finished one."
|
|
3223
|
+
);
|
|
3224
|
+
}
|
|
3225
|
+
console.log(`[import] chunk: nest=${target} files=${files.length}`);
|
|
3226
|
+
return c.json(await stageImportedFiles(target, files, email2));
|
|
3227
|
+
}
|
|
2886
3228
|
if (!body.name) {
|
|
2887
3229
|
throw new ValidationError("name is required");
|
|
2888
3230
|
}
|
|
2889
|
-
const files = Array.isArray(body.files) ? body.files : [];
|
|
2890
|
-
const userId = c.get("userId");
|
|
2891
3231
|
console.log(
|
|
2892
|
-
`[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
|
|
3232
|
+
`[import] request: name=${JSON.stringify(body.name)} files=${files.length}` + (body.partial ? " (first chunk)" : "")
|
|
2893
3233
|
);
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
await stream.writeln(JSON.stringify({ phase: "register", done, total }));
|
|
2906
|
-
}
|
|
2907
|
-
);
|
|
2908
|
-
await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
|
|
2909
|
-
} catch (err) {
|
|
2910
|
-
await stream.writeln(
|
|
2911
|
-
JSON.stringify({ error: err?.message || "Import failed" })
|
|
2912
|
-
);
|
|
2913
|
-
}
|
|
3234
|
+
const email = await resolveCallerEmail(userId);
|
|
3235
|
+
const nest = await importNest(userId, body.name);
|
|
3236
|
+
try {
|
|
3237
|
+
const { rejected } = await stageImportedFiles(nest.id, files, email);
|
|
3238
|
+
if (body.partial) {
|
|
3239
|
+
return c.json({ nest, rejected }, 201);
|
|
3240
|
+
}
|
|
3241
|
+
const documents = await registerImportedDocuments(nest.id, email);
|
|
3242
|
+
return c.json({ nest, documents, rejected }, 201);
|
|
3243
|
+
} catch (err) {
|
|
3244
|
+
await deleteNest(nest.id).catch(() => {
|
|
2914
3245
|
});
|
|
3246
|
+
throw err;
|
|
2915
3247
|
}
|
|
2916
|
-
const nest = await importNest(userId, body.name, files);
|
|
2917
|
-
const documents = await registerImportedDocuments(
|
|
2918
|
-
nest.id,
|
|
2919
|
-
await resolveCallerEmail(userId)
|
|
2920
|
-
);
|
|
2921
|
-
return c.json({ nest, documents }, 201);
|
|
2922
3248
|
});
|
|
2923
3249
|
async function handleUnsyncedList(c) {
|
|
2924
3250
|
const userId = c.get("userId");
|
|
@@ -2958,7 +3284,7 @@ nestRoutes.delete("/:nestId/sync", async (c) => {
|
|
|
2958
3284
|
if (!body?.name) {
|
|
2959
3285
|
throw new ValidationError("name is required");
|
|
2960
3286
|
}
|
|
2961
|
-
deleteUnsyncedFolder(body.name);
|
|
3287
|
+
await deleteUnsyncedFolder(body.name);
|
|
2962
3288
|
return c.json({ ok: true });
|
|
2963
3289
|
});
|
|
2964
3290
|
nestRoutes.get("/:nestId", async (c) => {
|
|
@@ -3023,7 +3349,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
|
|
|
3023
3349
|
}
|
|
3024
3350
|
return c.json({
|
|
3025
3351
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3026
|
-
allow_self_approve: await nestAllowsSelfApprove(nestId)
|
|
3352
|
+
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
3353
|
+
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
3354
|
+
prime_tags: await nestPrimeTags(nestId)
|
|
3027
3355
|
});
|
|
3028
3356
|
});
|
|
3029
3357
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
@@ -3059,9 +3387,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
3059
3387
|
if (typeof body.allow_self_approve === "boolean") {
|
|
3060
3388
|
await setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
3061
3389
|
}
|
|
3390
|
+
if (typeof body.prime_only_review === "boolean") {
|
|
3391
|
+
await setPrimeOnlyReview(nestId, body.prime_only_review);
|
|
3392
|
+
}
|
|
3393
|
+
if (body.prime_tags !== void 0) {
|
|
3394
|
+
await setPrimeTags(
|
|
3395
|
+
nestId,
|
|
3396
|
+
Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
|
|
3397
|
+
);
|
|
3398
|
+
}
|
|
3062
3399
|
return c.json({
|
|
3063
3400
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3064
3401
|
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
3402
|
+
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
3403
|
+
prime_tags: await nestPrimeTags(nestId),
|
|
3065
3404
|
wiped
|
|
3066
3405
|
});
|
|
3067
3406
|
});
|
|
@@ -3662,27 +4001,33 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
|
|
|
3662
4001
|
return c.json({ team });
|
|
3663
4002
|
});
|
|
3664
4003
|
|
|
4004
|
+
// src/nodes/search.ts
|
|
4005
|
+
function matchDocuments(documents, query) {
|
|
4006
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
4007
|
+
if (terms.length === 0) return [];
|
|
4008
|
+
return documents.filter((node) => {
|
|
4009
|
+
const haystack = [
|
|
4010
|
+
node.frontmatter.title,
|
|
4011
|
+
node.body || "",
|
|
4012
|
+
node.frontmatter.type || "",
|
|
4013
|
+
...node.frontmatter.tags || []
|
|
4014
|
+
].join(" ").toLowerCase();
|
|
4015
|
+
return terms.every((term) => haystack.includes(term));
|
|
4016
|
+
});
|
|
4017
|
+
}
|
|
4018
|
+
function toSearchHit(node) {
|
|
4019
|
+
return {
|
|
4020
|
+
id: node.id,
|
|
4021
|
+
title: node.frontmatter.title,
|
|
4022
|
+
type: node.frontmatter.type || "document",
|
|
4023
|
+
tags: node.frontmatter.tags || [],
|
|
4024
|
+
snippet: (node.body || "").slice(0, 200).replace(/\n/g, " ")
|
|
4025
|
+
};
|
|
4026
|
+
}
|
|
4027
|
+
|
|
3665
4028
|
// src/nodes/routes.ts
|
|
3666
4029
|
import { Hono as Hono8 } from "hono";
|
|
3667
4030
|
|
|
3668
|
-
// src/shared/artifact-sandbox.ts
|
|
3669
|
-
var ARTIFACT_SANDBOX_CSP = [
|
|
3670
|
-
"sandbox allow-scripts allow-popups allow-modals",
|
|
3671
|
-
"default-src 'none'",
|
|
3672
|
-
"script-src 'unsafe-inline'",
|
|
3673
|
-
"style-src 'unsafe-inline'",
|
|
3674
|
-
"img-src data: blob:",
|
|
3675
|
-
"font-src data:",
|
|
3676
|
-
"connect-src 'none'",
|
|
3677
|
-
"base-uri 'none'",
|
|
3678
|
-
"form-action 'none'"
|
|
3679
|
-
].join("; ");
|
|
3680
|
-
function applyArtifactSandboxHeaders(c) {
|
|
3681
|
-
c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
|
|
3682
|
-
c.header("X-Content-Type-Options", "nosniff");
|
|
3683
|
-
c.header("Referrer-Policy", "no-referrer");
|
|
3684
|
-
}
|
|
3685
|
-
|
|
3686
4031
|
// src/nodes/markdown-export.ts
|
|
3687
4032
|
import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
|
|
3688
4033
|
function nodeToMarkdown(node) {
|
|
@@ -3732,311 +4077,23 @@ ${m[1]}
|
|
|
3732
4077
|
` : "";
|
|
3733
4078
|
}
|
|
3734
4079
|
|
|
3735
|
-
// src/
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
3746
|
-
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
3747
|
-
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
3748
|
-
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
3749
|
-
}
|
|
3750
|
-
function oneLine(s) {
|
|
3751
|
-
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
4080
|
+
// src/nodes/routes.ts
|
|
4081
|
+
var nodeRoutes = new Hono8();
|
|
4082
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
4083
|
+
return nodeToMarkdown({
|
|
4084
|
+
id: nodeId,
|
|
4085
|
+
title: response.title,
|
|
4086
|
+
tags: response.tags,
|
|
4087
|
+
status: response.status,
|
|
4088
|
+
body: response.content
|
|
4089
|
+
});
|
|
3752
4090
|
}
|
|
3753
|
-
function
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
anchor.after
|
|
3760
|
-
)}\u2026`;
|
|
3761
|
-
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
3762
|
-
}
|
|
3763
|
-
function statusHeading(thread) {
|
|
3764
|
-
if (thread.status === "resolved") {
|
|
3765
|
-
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
3766
|
-
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
3767
|
-
const meta = who || when ? ` (${who}${when})` : "";
|
|
3768
|
-
return `\u2705 RESOLVED${meta}`;
|
|
3769
|
-
}
|
|
3770
|
-
return "\u{1F7E0} OPEN";
|
|
3771
|
-
}
|
|
3772
|
-
function snapshotLabel(v) {
|
|
3773
|
-
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
3774
|
-
}
|
|
3775
|
-
function orderThreads(threads) {
|
|
3776
|
-
return [...threads].sort((a, b) => {
|
|
3777
|
-
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
3778
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
3779
|
-
});
|
|
3780
|
-
}
|
|
3781
|
-
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
3782
|
-
const lines = [];
|
|
3783
|
-
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
3784
|
-
lines.push("");
|
|
3785
|
-
lines.push(
|
|
3786
|
-
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
3787
|
-
);
|
|
3788
|
-
lines.push("");
|
|
3789
|
-
if (threads.length === 0) {
|
|
3790
|
-
lines.push("_No annotations yet._");
|
|
3791
|
-
lines.push("");
|
|
3792
|
-
return lines.join("\n");
|
|
3793
|
-
}
|
|
3794
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
3795
|
-
for (const t of threads) {
|
|
3796
|
-
const key = t.snapshotVersion ?? null;
|
|
3797
|
-
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
3798
|
-
}
|
|
3799
|
-
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
3800
|
-
if (a == null) return 1;
|
|
3801
|
-
if (b == null) return -1;
|
|
3802
|
-
return b - a;
|
|
3803
|
-
});
|
|
3804
|
-
for (const key of keys) {
|
|
3805
|
-
lines.push(`## ${snapshotLabel(key)}`);
|
|
3806
|
-
lines.push("");
|
|
3807
|
-
for (const thread of orderThreads(buckets2.get(key))) {
|
|
3808
|
-
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
3809
|
-
lines.push(renderAnchor(thread.anchor));
|
|
3810
|
-
for (const comment of thread.comments) {
|
|
3811
|
-
lines.push(
|
|
3812
|
-
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
3813
|
-
);
|
|
3814
|
-
}
|
|
3815
|
-
lines.push("");
|
|
3816
|
-
}
|
|
3817
|
-
}
|
|
3818
|
-
return lines.join("\n");
|
|
3819
|
-
}
|
|
3820
|
-
|
|
3821
|
-
// src/annotations/service.ts
|
|
3822
|
-
async function loadComments(threadId) {
|
|
3823
|
-
const db = getDb();
|
|
3824
|
-
const rows = await db.all(
|
|
3825
|
-
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
3826
|
-
[threadId]
|
|
3827
|
-
);
|
|
3828
|
-
return rows.map((r) => ({
|
|
3829
|
-
id: r.id,
|
|
3830
|
-
author: r.author,
|
|
3831
|
-
body: r.body,
|
|
3832
|
-
createdAt: r.created_at
|
|
3833
|
-
}));
|
|
3834
|
-
}
|
|
3835
|
-
async function rowToThread(row) {
|
|
3836
|
-
let anchor = null;
|
|
3837
|
-
if (row.anchor_json) {
|
|
3838
|
-
try {
|
|
3839
|
-
anchor = JSON.parse(row.anchor_json);
|
|
3840
|
-
} catch {
|
|
3841
|
-
anchor = null;
|
|
3842
|
-
}
|
|
3843
|
-
}
|
|
3844
|
-
return {
|
|
3845
|
-
id: row.id,
|
|
3846
|
-
nestId: row.nest_id,
|
|
3847
|
-
nodeId: row.node_id,
|
|
3848
|
-
snapshotVersion: row.snapshot_version,
|
|
3849
|
-
anchor,
|
|
3850
|
-
status: row.status,
|
|
3851
|
-
createdBy: row.created_by,
|
|
3852
|
-
createdAt: row.created_at,
|
|
3853
|
-
resolvedBy: row.resolved_by,
|
|
3854
|
-
resolvedAt: row.resolved_at,
|
|
3855
|
-
comments: await loadComments(row.id)
|
|
3856
|
-
};
|
|
3857
|
-
}
|
|
3858
|
-
async function getThreadRow(threadId) {
|
|
3859
|
-
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
3860
|
-
}
|
|
3861
|
-
async function countThreadsByNode(nestId) {
|
|
3862
|
-
const rows = await getDb().all(
|
|
3863
|
-
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
3864
|
-
FROM annotation_threads
|
|
3865
|
-
WHERE nest_id = ?
|
|
3866
|
-
GROUP BY node_id`,
|
|
3867
|
-
[nestId]
|
|
3868
|
-
);
|
|
3869
|
-
const out = {};
|
|
3870
|
-
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
3871
|
-
return out;
|
|
3872
|
-
}
|
|
3873
|
-
async function listThreads(nestId, nodeId) {
|
|
3874
|
-
const rows = await getDb().all(
|
|
3875
|
-
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
3876
|
-
[nestId, nodeId]
|
|
3877
|
-
);
|
|
3878
|
-
return Promise.all(rows.map(rowToThread));
|
|
3879
|
-
}
|
|
3880
|
-
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
3881
|
-
const body = (input.body ?? "").trim();
|
|
3882
|
-
if (!body) {
|
|
3883
|
-
throw new Error("comment body is required");
|
|
3884
|
-
}
|
|
3885
|
-
const db = getDb();
|
|
3886
|
-
const id = uuid5();
|
|
3887
|
-
const anchor = clampAnchor(input.anchor);
|
|
3888
|
-
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3889
|
-
await db.transaction(async (tx) => {
|
|
3890
|
-
await tx.run(
|
|
3891
|
-
`INSERT INTO annotation_threads
|
|
3892
|
-
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
3893
|
-
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
3894
|
-
[
|
|
3895
|
-
id,
|
|
3896
|
-
nestId,
|
|
3897
|
-
nodeId,
|
|
3898
|
-
snapshot,
|
|
3899
|
-
anchor ? JSON.stringify(anchor) : null,
|
|
3900
|
-
authorEmail
|
|
3901
|
-
]
|
|
3902
|
-
);
|
|
3903
|
-
await tx.run(
|
|
3904
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3905
|
-
[uuid5(), id, authorEmail, body]
|
|
3906
|
-
);
|
|
3907
|
-
});
|
|
3908
|
-
return rowToThread(await getThreadRow(id));
|
|
3909
|
-
}
|
|
3910
|
-
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
3911
|
-
const row = await getThreadRow(threadId);
|
|
3912
|
-
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
3913
|
-
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
3914
|
-
}
|
|
3915
|
-
return row;
|
|
3916
|
-
}
|
|
3917
|
-
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
3918
|
-
const trimmed = (body ?? "").trim();
|
|
3919
|
-
if (!trimmed) {
|
|
3920
|
-
throw new Error("comment body is required");
|
|
3921
|
-
}
|
|
3922
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3923
|
-
await getDb().run(
|
|
3924
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3925
|
-
[uuid5(), threadId, authorEmail, trimmed]
|
|
3926
|
-
);
|
|
3927
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3928
|
-
}
|
|
3929
|
-
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
3930
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3931
|
-
const db = getDb();
|
|
3932
|
-
if (status === "resolved") {
|
|
3933
|
-
await db.run(
|
|
3934
|
-
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
3935
|
-
[byEmail, threadId]
|
|
3936
|
-
);
|
|
3937
|
-
} else {
|
|
3938
|
-
await db.run(
|
|
3939
|
-
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
3940
|
-
[threadId]
|
|
3941
|
-
);
|
|
3942
|
-
}
|
|
3943
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3944
|
-
}
|
|
3945
|
-
async function readArtifact(nestId, nodeId, version) {
|
|
3946
|
-
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
3947
|
-
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3948
|
-
let title = nodeId;
|
|
3949
|
-
let type = null;
|
|
3950
|
-
let liveHtml = "";
|
|
3951
|
-
let liveExists = true;
|
|
3952
|
-
try {
|
|
3953
|
-
const node = await storage.readDocument(nodeId);
|
|
3954
|
-
title = node.frontmatter?.title || nodeId;
|
|
3955
|
-
type = node.frontmatter?.type || null;
|
|
3956
|
-
liveHtml = node.body || "";
|
|
3957
|
-
} catch {
|
|
3958
|
-
liveExists = false;
|
|
3959
|
-
}
|
|
3960
|
-
if (target != null) {
|
|
3961
|
-
try {
|
|
3962
|
-
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
3963
|
-
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
3964
|
-
} catch (err) {
|
|
3965
|
-
console.warn(
|
|
3966
|
-
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
3967
|
-
err
|
|
3968
|
-
);
|
|
3969
|
-
}
|
|
3970
|
-
}
|
|
3971
|
-
if (!liveExists) {
|
|
3972
|
-
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
3973
|
-
}
|
|
3974
|
-
return { title, html: liveHtml, version: target, type };
|
|
3975
|
-
}
|
|
3976
|
-
function derivedAnnotationsId(sourceNodeId) {
|
|
3977
|
-
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
3978
|
-
return `nodes/${base}--annotations`;
|
|
3979
|
-
}
|
|
3980
|
-
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
3981
|
-
try {
|
|
3982
|
-
let title = nodeId;
|
|
3983
|
-
let type = null;
|
|
3984
|
-
try {
|
|
3985
|
-
const { storage } = await engineCache.get(nestId);
|
|
3986
|
-
const node = await storage.readDocument(nodeId);
|
|
3987
|
-
title = node.frontmatter?.title || nodeId;
|
|
3988
|
-
type = node.frontmatter?.type || "document";
|
|
3989
|
-
} catch {
|
|
3990
|
-
}
|
|
3991
|
-
if (type !== null && type !== "artifact") return;
|
|
3992
|
-
const threads = await listThreads(nestId, nodeId);
|
|
3993
|
-
const derivedTitle = `${title} \u2014 Annotations`;
|
|
3994
|
-
const markdown = projectThreadsToMarkdown(title, threads);
|
|
3995
|
-
const derivedId = derivedAnnotationsId(nodeId);
|
|
3996
|
-
try {
|
|
3997
|
-
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
3998
|
-
} catch (err) {
|
|
3999
|
-
if (err instanceof NotFoundError) {
|
|
4000
|
-
await createNode(
|
|
4001
|
-
nestId,
|
|
4002
|
-
{
|
|
4003
|
-
id: derivedId,
|
|
4004
|
-
title: derivedTitle,
|
|
4005
|
-
content: markdown,
|
|
4006
|
-
type: "document",
|
|
4007
|
-
tags: ["annotations"]
|
|
4008
|
-
},
|
|
4009
|
-
userEmail
|
|
4010
|
-
);
|
|
4011
|
-
} else {
|
|
4012
|
-
throw err;
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
} catch (err) {
|
|
4016
|
-
console.warn(
|
|
4017
|
-
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
4018
|
-
err
|
|
4019
|
-
);
|
|
4020
|
-
}
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
// src/nodes/routes.ts
|
|
4024
|
-
var nodeRoutes = new Hono8();
|
|
4025
|
-
function nodeAsMarkdown(response, nodeId) {
|
|
4026
|
-
return nodeToMarkdown({
|
|
4027
|
-
id: nodeId,
|
|
4028
|
-
title: response.title,
|
|
4029
|
-
tags: response.tags,
|
|
4030
|
-
status: response.status,
|
|
4031
|
-
body: response.content
|
|
4032
|
-
});
|
|
4033
|
-
}
|
|
4034
|
-
function rawNodeBody(c, response, storedRaw) {
|
|
4035
|
-
const body = response.content ?? "";
|
|
4036
|
-
const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
|
|
4037
|
-
c.header("X-Content-Type-Options", "nosniff");
|
|
4038
|
-
if ((response.type || "document") === "artifact") {
|
|
4039
|
-
applyArtifactSandboxHeaders(c);
|
|
4091
|
+
function rawNodeBody(c, response, storedRaw) {
|
|
4092
|
+
const body = response.content ?? "";
|
|
4093
|
+
const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
|
|
4094
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
4095
|
+
if ((response.type || "document") === "artifact") {
|
|
4096
|
+
applyArtifactSandboxHeaders(c);
|
|
4040
4097
|
}
|
|
4041
4098
|
return c.body(payload, 200, {
|
|
4042
4099
|
"Content-Type": rawContentType(response.type)
|
|
@@ -4045,7 +4102,17 @@ function rawNodeBody(c, response, storedRaw) {
|
|
|
4045
4102
|
nodeRoutes.get("/", async (c) => {
|
|
4046
4103
|
const nestId = c.req.param("nestId");
|
|
4047
4104
|
const userId = c.get("userId");
|
|
4048
|
-
const
|
|
4105
|
+
const q = c.req.query();
|
|
4106
|
+
const limit = q.limit ? parseInt(q.limit, 10) : void 0;
|
|
4107
|
+
const nodes = await listNodesForCaller(nestId, userId, {
|
|
4108
|
+
...q.type ? { type: q.type } : {},
|
|
4109
|
+
...q.tag ? { tag: q.tag } : {},
|
|
4110
|
+
...q.status ? { status: q.status } : {},
|
|
4111
|
+
// Lets an owner preview what making the nest public would expose.
|
|
4112
|
+
...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
|
|
4113
|
+
// Ignore junk rather than 400 — a bad ?limit should not fail a listing.
|
|
4114
|
+
...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
|
|
4115
|
+
});
|
|
4049
4116
|
return c.json({ count: nodes.length, nodes });
|
|
4050
4117
|
});
|
|
4051
4118
|
nodeRoutes.post("/", async (c) => {
|
|
@@ -4068,7 +4135,8 @@ nodeRoutes.post("/", async (c) => {
|
|
|
4068
4135
|
scope: body.scope,
|
|
4069
4136
|
status: body.status,
|
|
4070
4137
|
schedule: body.schedule,
|
|
4071
|
-
folder: body.folder
|
|
4138
|
+
folder: body.folder,
|
|
4139
|
+
prime: body.prime
|
|
4072
4140
|
},
|
|
4073
4141
|
authorEmail
|
|
4074
4142
|
);
|
|
@@ -4085,7 +4153,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
4085
4153
|
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
4086
4154
|
const nestId = c.req.param("nestId");
|
|
4087
4155
|
const nodeId = c.req.param("nodeId");
|
|
4088
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
4156
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-LTREGVSJ.js");
|
|
4089
4157
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
4090
4158
|
nestId,
|
|
4091
4159
|
nodeId
|
|
@@ -4103,42 +4171,10 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
4103
4171
|
ownerEmail
|
|
4104
4172
|
});
|
|
4105
4173
|
});
|
|
4106
|
-
nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
4107
|
-
const nestId = c.req.param("nestId");
|
|
4108
|
-
const nodeId = c.req.param("nodeId");
|
|
4109
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FGL47APL.js");
|
|
4110
|
-
const allVersions = await getVersions2(nestId, nodeId);
|
|
4111
|
-
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
4112
|
-
const db = getDb();
|
|
4113
|
-
const resolutions = await db.all(
|
|
4114
|
-
`SELECT version, status, resolved_by, resolved_at
|
|
4115
|
-
FROM review_requests
|
|
4116
|
-
WHERE nest_id = ? AND node_id = ?
|
|
4117
|
-
AND status IN ('approved', 'rejected')
|
|
4118
|
-
AND resolved_by IS NOT NULL
|
|
4119
|
-
ORDER BY resolved_at DESC`,
|
|
4120
|
-
[nestId, nodeId]
|
|
4121
|
-
);
|
|
4122
|
-
const byVersion = /* @__PURE__ */ new Map();
|
|
4123
|
-
for (const r of resolutions) {
|
|
4124
|
-
if (!byVersion.has(r.version)) {
|
|
4125
|
-
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
const enriched = allVersions.map((v) => {
|
|
4129
|
-
const r = byVersion.get(v.version);
|
|
4130
|
-
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
4131
|
-
});
|
|
4132
|
-
return c.json({
|
|
4133
|
-
versions: enriched,
|
|
4134
|
-
approvedVersion: approved,
|
|
4135
|
-
currentVersion: allVersions[0]?.version || 0
|
|
4136
|
-
});
|
|
4137
|
-
});
|
|
4138
4174
|
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
4139
4175
|
const nestId = c.req.param("nestId");
|
|
4140
4176
|
const nodeId = c.req.param("nodeId");
|
|
4141
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
4177
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-V2GR6VM3.js");
|
|
4142
4178
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
4143
4179
|
return c.json({ reviews: history });
|
|
4144
4180
|
});
|
|
@@ -4227,18 +4263,13 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
4227
4263
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
4228
4264
|
const userId = c.get("userId");
|
|
4229
4265
|
const userEmail = await resolveCallerEmail(userId);
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
try {
|
|
4238
|
-
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
4239
|
-
} catch {
|
|
4240
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
4241
|
-
}
|
|
4266
|
+
let node = await getNodeForCaller(
|
|
4267
|
+
nestId,
|
|
4268
|
+
{ id: nodeId },
|
|
4269
|
+
userId,
|
|
4270
|
+
userEmail,
|
|
4271
|
+
{ verifyChecksum: true }
|
|
4272
|
+
);
|
|
4242
4273
|
if (node.pendingChange) {
|
|
4243
4274
|
try {
|
|
4244
4275
|
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
@@ -4304,6 +4335,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
4304
4335
|
const pending = await getPendingReview(nestId, nodeId);
|
|
4305
4336
|
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
4306
4337
|
}
|
|
4338
|
+
response.deletionRequest = await getLatestDeletionRequest(
|
|
4339
|
+
nestId,
|
|
4340
|
+
nodeId
|
|
4341
|
+
);
|
|
4307
4342
|
if (isRawFormat(c)) {
|
|
4308
4343
|
return rawNodeBody(c, response, node.rawContent);
|
|
4309
4344
|
}
|
|
@@ -4347,93 +4382,317 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
4347
4382
|
title: body.title,
|
|
4348
4383
|
status: body.status,
|
|
4349
4384
|
changeNote: body.changeNote,
|
|
4350
|
-
schedule: body.schedule
|
|
4385
|
+
schedule: body.schedule,
|
|
4386
|
+
prime: body.prime
|
|
4351
4387
|
},
|
|
4352
4388
|
authorEmail
|
|
4353
4389
|
);
|
|
4354
|
-
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
4355
|
-
});
|
|
4356
|
-
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
4357
|
-
const nestId = c.req.param("nestId");
|
|
4358
|
-
const nodeId = c.req.param("nodeId");
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4390
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
4391
|
+
});
|
|
4392
|
+
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
4393
|
+
const nestId = c.req.param("nestId");
|
|
4394
|
+
const nodeId = c.req.param("nodeId");
|
|
4395
|
+
await deleteNode(nestId, nodeId, await getUserEmail(c));
|
|
4396
|
+
return c.json({ deleted: true });
|
|
4397
|
+
});
|
|
4398
|
+
async function getUserEmail(c) {
|
|
4399
|
+
const userId = c.get("userId");
|
|
4400
|
+
const db = getDb();
|
|
4401
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4402
|
+
return user?.email || "anonymous@localhost";
|
|
4403
|
+
}
|
|
4404
|
+
|
|
4405
|
+
// src/annotations/routes.ts
|
|
4406
|
+
import { Hono as Hono9 } from "hono";
|
|
4407
|
+
|
|
4408
|
+
// src/annotations/service.ts
|
|
4409
|
+
import { v4 as uuid5 } from "uuid";
|
|
4410
|
+
|
|
4411
|
+
// src/annotations/projection.ts
|
|
4412
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
4413
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
4414
|
+
function clampAnchor(raw) {
|
|
4415
|
+
if (!raw) return null;
|
|
4416
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
4417
|
+
if (!quote) return null;
|
|
4418
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
4419
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
4420
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
4421
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
4422
|
+
}
|
|
4423
|
+
function oneLine(s) {
|
|
4424
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
4425
|
+
}
|
|
4426
|
+
function renderAnchor(anchor) {
|
|
4427
|
+
if (!anchor) {
|
|
4428
|
+
return "<!-- anchor: whole-artifact -->";
|
|
4429
|
+
}
|
|
4430
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
4431
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
4432
|
+
anchor.after
|
|
4433
|
+
)}\u2026`;
|
|
4434
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
4435
|
+
}
|
|
4436
|
+
function statusHeading(thread) {
|
|
4437
|
+
if (thread.status === "resolved") {
|
|
4438
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
4439
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
4440
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
4441
|
+
return `\u2705 RESOLVED${meta}`;
|
|
4442
|
+
}
|
|
4443
|
+
return "\u{1F7E0} OPEN";
|
|
4444
|
+
}
|
|
4445
|
+
function snapshotLabel(v) {
|
|
4446
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
4447
|
+
}
|
|
4448
|
+
function orderThreads(threads) {
|
|
4449
|
+
return [...threads].sort((a, b) => {
|
|
4450
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
4451
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
4452
|
+
});
|
|
4453
|
+
}
|
|
4454
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
4455
|
+
const lines = [];
|
|
4456
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
4457
|
+
lines.push("");
|
|
4458
|
+
lines.push(
|
|
4459
|
+
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
4460
|
+
);
|
|
4461
|
+
lines.push("");
|
|
4462
|
+
if (threads.length === 0) {
|
|
4463
|
+
lines.push("_No annotations yet._");
|
|
4464
|
+
lines.push("");
|
|
4465
|
+
return lines.join("\n");
|
|
4466
|
+
}
|
|
4467
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
4468
|
+
for (const t of threads) {
|
|
4469
|
+
const key = t.snapshotVersion ?? null;
|
|
4470
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
4471
|
+
}
|
|
4472
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
4473
|
+
if (a == null) return 1;
|
|
4474
|
+
if (b == null) return -1;
|
|
4475
|
+
return b - a;
|
|
4476
|
+
});
|
|
4477
|
+
for (const key of keys) {
|
|
4478
|
+
lines.push(`## ${snapshotLabel(key)}`);
|
|
4479
|
+
lines.push("");
|
|
4480
|
+
for (const thread of orderThreads(buckets2.get(key))) {
|
|
4481
|
+
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
4482
|
+
lines.push(renderAnchor(thread.anchor));
|
|
4483
|
+
for (const comment of thread.comments) {
|
|
4484
|
+
lines.push(
|
|
4485
|
+
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
4486
|
+
);
|
|
4487
|
+
}
|
|
4488
|
+
lines.push("");
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
return lines.join("\n");
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
// src/annotations/service.ts
|
|
4495
|
+
async function loadComments(threadId) {
|
|
4496
|
+
const db = getDb();
|
|
4497
|
+
const rows = await db.all(
|
|
4498
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
4499
|
+
[threadId]
|
|
4500
|
+
);
|
|
4501
|
+
return rows.map((r) => ({
|
|
4502
|
+
id: r.id,
|
|
4503
|
+
author: r.author,
|
|
4504
|
+
body: r.body,
|
|
4505
|
+
createdAt: r.created_at
|
|
4506
|
+
}));
|
|
4507
|
+
}
|
|
4508
|
+
async function rowToThread(row) {
|
|
4509
|
+
let anchor = null;
|
|
4510
|
+
if (row.anchor_json) {
|
|
4511
|
+
try {
|
|
4512
|
+
anchor = JSON.parse(row.anchor_json);
|
|
4513
|
+
} catch {
|
|
4514
|
+
anchor = null;
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
return {
|
|
4518
|
+
id: row.id,
|
|
4519
|
+
nestId: row.nest_id,
|
|
4520
|
+
nodeId: row.node_id,
|
|
4521
|
+
snapshotVersion: row.snapshot_version,
|
|
4522
|
+
anchor,
|
|
4523
|
+
status: row.status,
|
|
4524
|
+
createdBy: row.created_by,
|
|
4525
|
+
createdAt: row.created_at,
|
|
4526
|
+
resolvedBy: row.resolved_by,
|
|
4527
|
+
resolvedAt: row.resolved_at,
|
|
4528
|
+
comments: await loadComments(row.id)
|
|
4529
|
+
};
|
|
4530
|
+
}
|
|
4531
|
+
async function getThreadRow(threadId) {
|
|
4532
|
+
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
4533
|
+
}
|
|
4534
|
+
async function countThreadsByNode(nestId) {
|
|
4535
|
+
const rows = await getDb().all(
|
|
4536
|
+
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
4537
|
+
FROM annotation_threads
|
|
4538
|
+
WHERE nest_id = ?
|
|
4539
|
+
GROUP BY node_id`,
|
|
4540
|
+
[nestId]
|
|
4541
|
+
);
|
|
4542
|
+
const out = {};
|
|
4543
|
+
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
4544
|
+
return out;
|
|
4545
|
+
}
|
|
4546
|
+
async function listThreads(nestId, nodeId) {
|
|
4547
|
+
const rows = await getDb().all(
|
|
4548
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
4549
|
+
[nestId, nodeId]
|
|
4550
|
+
);
|
|
4551
|
+
return Promise.all(rows.map(rowToThread));
|
|
4552
|
+
}
|
|
4553
|
+
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
4554
|
+
const body = (input.body ?? "").trim();
|
|
4555
|
+
if (!body) {
|
|
4556
|
+
throw new Error("comment body is required");
|
|
4376
4557
|
}
|
|
4377
4558
|
const db = getDb();
|
|
4559
|
+
const id = uuid5();
|
|
4560
|
+
const anchor = clampAnchor(input.anchor);
|
|
4561
|
+
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
4378
4562
|
await db.transaction(async (tx) => {
|
|
4379
4563
|
await tx.run(
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
await tx.run(
|
|
4392
|
-
`DELETE FROM stewards
|
|
4393
|
-
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
4394
|
-
[nestId, nodeId]
|
|
4395
|
-
);
|
|
4396
|
-
await tx.run(
|
|
4397
|
-
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
4398
|
-
[nestId, nodeId]
|
|
4399
|
-
);
|
|
4400
|
-
await tx.run(
|
|
4401
|
-
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
4402
|
-
[nestId, derivedId]
|
|
4564
|
+
`INSERT INTO annotation_threads
|
|
4565
|
+
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
4566
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
4567
|
+
[
|
|
4568
|
+
id,
|
|
4569
|
+
nestId,
|
|
4570
|
+
nodeId,
|
|
4571
|
+
snapshot,
|
|
4572
|
+
anchor ? JSON.stringify(anchor) : null,
|
|
4573
|
+
authorEmail
|
|
4574
|
+
]
|
|
4403
4575
|
);
|
|
4404
4576
|
await tx.run(
|
|
4405
|
-
"
|
|
4406
|
-
[
|
|
4577
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
4578
|
+
[uuid5(), id, authorEmail, body]
|
|
4407
4579
|
);
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4580
|
+
});
|
|
4581
|
+
return rowToThread(await getThreadRow(id));
|
|
4582
|
+
}
|
|
4583
|
+
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
4584
|
+
const row = await getThreadRow(threadId);
|
|
4585
|
+
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
4586
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
4587
|
+
}
|
|
4588
|
+
return row;
|
|
4589
|
+
}
|
|
4590
|
+
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
4591
|
+
const trimmed = (body ?? "").trim();
|
|
4592
|
+
if (!trimmed) {
|
|
4593
|
+
throw new Error("comment body is required");
|
|
4594
|
+
}
|
|
4595
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
4596
|
+
await getDb().run(
|
|
4597
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
4598
|
+
[uuid5(), threadId, authorEmail, trimmed]
|
|
4599
|
+
);
|
|
4600
|
+
return rowToThread(await getThreadRow(threadId));
|
|
4601
|
+
}
|
|
4602
|
+
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
4603
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
4604
|
+
const db = getDb();
|
|
4605
|
+
if (status === "resolved") {
|
|
4606
|
+
await db.run(
|
|
4607
|
+
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
4608
|
+
[byEmail, threadId]
|
|
4411
4609
|
);
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4610
|
+
} else {
|
|
4611
|
+
await db.run(
|
|
4612
|
+
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
4613
|
+
[threadId]
|
|
4415
4614
|
);
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4615
|
+
}
|
|
4616
|
+
return rowToThread(await getThreadRow(threadId));
|
|
4617
|
+
}
|
|
4618
|
+
async function readArtifact(nestId, nodeId, version) {
|
|
4619
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
4620
|
+
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
4621
|
+
let title = nodeId;
|
|
4622
|
+
let type = null;
|
|
4623
|
+
let liveHtml = "";
|
|
4624
|
+
let liveExists = true;
|
|
4625
|
+
try {
|
|
4626
|
+
const node = await storage.readDocument(nodeId);
|
|
4627
|
+
title = node.frontmatter?.title || nodeId;
|
|
4628
|
+
type = node.frontmatter?.type || null;
|
|
4629
|
+
liveHtml = node.body || "";
|
|
4630
|
+
} catch {
|
|
4631
|
+
liveExists = false;
|
|
4632
|
+
}
|
|
4633
|
+
if (target != null) {
|
|
4634
|
+
try {
|
|
4635
|
+
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
4636
|
+
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
4637
|
+
} catch (err) {
|
|
4638
|
+
console.warn(
|
|
4639
|
+
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
4640
|
+
err
|
|
4641
|
+
);
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
if (!liveExists) {
|
|
4645
|
+
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
4646
|
+
}
|
|
4647
|
+
return { title, html: liveHtml, version: target, type };
|
|
4648
|
+
}
|
|
4649
|
+
function derivedAnnotationsId(sourceNodeId) {
|
|
4650
|
+
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
4651
|
+
return `nodes/${base}--annotations`;
|
|
4652
|
+
}
|
|
4653
|
+
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
4654
|
+
try {
|
|
4655
|
+
let title = nodeId;
|
|
4656
|
+
let type = null;
|
|
4657
|
+
try {
|
|
4658
|
+
const { storage } = await engineCache.get(nestId);
|
|
4659
|
+
const node = await storage.readDocument(nodeId);
|
|
4660
|
+
title = node.frontmatter?.title || nodeId;
|
|
4661
|
+
type = node.frontmatter?.type || "document";
|
|
4662
|
+
} catch {
|
|
4663
|
+
}
|
|
4664
|
+
if (type !== null && type !== "artifact") return;
|
|
4665
|
+
const threads = await listThreads(nestId, nodeId);
|
|
4666
|
+
const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
|
|
4667
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
4668
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
4669
|
+
try {
|
|
4670
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
4671
|
+
} catch (err) {
|
|
4672
|
+
if (err instanceof NotFoundError) {
|
|
4673
|
+
await createNode(
|
|
4674
|
+
nestId,
|
|
4675
|
+
{
|
|
4676
|
+
id: derivedId,
|
|
4677
|
+
title: derivedTitle,
|
|
4678
|
+
content: markdown,
|
|
4679
|
+
type: "document",
|
|
4680
|
+
tags: ["annotations"]
|
|
4681
|
+
},
|
|
4682
|
+
userEmail
|
|
4683
|
+
);
|
|
4684
|
+
} else {
|
|
4685
|
+
throw err;
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
} catch (err) {
|
|
4689
|
+
console.warn(
|
|
4690
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
4691
|
+
err
|
|
4422
4692
|
);
|
|
4423
|
-
}
|
|
4424
|
-
await trackEvent("node.delete", { nestId, nodeId });
|
|
4425
|
-
return c.json({ deleted: true });
|
|
4426
|
-
});
|
|
4427
|
-
async function getUserEmail(c) {
|
|
4428
|
-
const userId = c.get("userId");
|
|
4429
|
-
const db = getDb();
|
|
4430
|
-
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4431
|
-
return user?.email || "anonymous@localhost";
|
|
4693
|
+
}
|
|
4432
4694
|
}
|
|
4433
4695
|
|
|
4434
|
-
// src/annotations/routes.ts
|
|
4435
|
-
import { Hono as Hono9 } from "hono";
|
|
4436
|
-
|
|
4437
4696
|
// src/annotations/types.ts
|
|
4438
4697
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
4439
4698
|
|
|
@@ -5527,29 +5786,14 @@ queryRoutes.get("/search", async (c) => {
|
|
|
5527
5786
|
const nestId = c.req.param("nestId");
|
|
5528
5787
|
const { storage } = await engineCache.get(nestId);
|
|
5529
5788
|
const documents = await storage.discoverDocuments();
|
|
5530
|
-
const
|
|
5531
|
-
const matches = documents.filter((node) => {
|
|
5532
|
-
const haystack = [
|
|
5533
|
-
node.frontmatter.title,
|
|
5534
|
-
node.body || "",
|
|
5535
|
-
node.frontmatter.type || "",
|
|
5536
|
-
...node.frontmatter.tags || []
|
|
5537
|
-
].join(" ").toLowerCase();
|
|
5538
|
-
return terms.every((term) => haystack.includes(term));
|
|
5539
|
-
});
|
|
5789
|
+
const matches = matchDocuments(documents, q);
|
|
5540
5790
|
const userId = c.get("userId");
|
|
5541
5791
|
const userEmail = await resolveCallerEmail(userId);
|
|
5542
5792
|
const accessible = await filterAccessible(nestId, userId, userEmail, matches);
|
|
5543
5793
|
return c.json({
|
|
5544
5794
|
query: q,
|
|
5545
5795
|
count: accessible.length,
|
|
5546
|
-
nodes: accessible.map(
|
|
5547
|
-
id: n.id,
|
|
5548
|
-
title: n.frontmatter.title,
|
|
5549
|
-
type: n.frontmatter.type || "document",
|
|
5550
|
-
tags: n.frontmatter.tags || [],
|
|
5551
|
-
snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
|
|
5552
|
-
}))
|
|
5796
|
+
nodes: accessible.map(toSearchHit)
|
|
5553
5797
|
});
|
|
5554
5798
|
});
|
|
5555
5799
|
queryRoutes.get("/overview", async (c) => {
|
|
@@ -5708,6 +5952,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
5708
5952
|
await syncNodeTags(nestId, id, tags);
|
|
5709
5953
|
created.push(id);
|
|
5710
5954
|
}
|
|
5955
|
+
if (created.length > 0 || body.context_md) await storage.regenerateIndex();
|
|
5711
5956
|
trackEvent("nest.publish", { nestId, count: created.length });
|
|
5712
5957
|
return c.json(
|
|
5713
5958
|
{
|
|
@@ -5724,6 +5969,30 @@ import { Hono as Hono12 } from "hono";
|
|
|
5724
5969
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5725
5970
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
5726
5971
|
|
|
5972
|
+
// src/shared/node-id.ts
|
|
5973
|
+
function assertSafeNodeId(rawId) {
|
|
5974
|
+
let id = rawId;
|
|
5975
|
+
try {
|
|
5976
|
+
id = decodeURIComponent(rawId);
|
|
5977
|
+
} catch {
|
|
5978
|
+
}
|
|
5979
|
+
if (!id || id.length > 512) {
|
|
5980
|
+
throw new ValidationError("invalid node id");
|
|
5981
|
+
}
|
|
5982
|
+
if (id.includes("\0") || id.includes("\\")) {
|
|
5983
|
+
throw new ValidationError("invalid node id");
|
|
5984
|
+
}
|
|
5985
|
+
if (id.startsWith("/")) {
|
|
5986
|
+
throw new ValidationError("invalid node id (absolute path)");
|
|
5987
|
+
}
|
|
5988
|
+
for (const seg of id.split("/")) {
|
|
5989
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
5990
|
+
throw new ValidationError("invalid node id (path traversal)");
|
|
5991
|
+
}
|
|
5992
|
+
}
|
|
5993
|
+
return id;
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5727
5996
|
// src/telemetry/trace-log.ts
|
|
5728
5997
|
var PRUNE_EVERY = 500;
|
|
5729
5998
|
var insertsSincePrune = 0;
|
|
@@ -5794,38 +6063,14 @@ async function listTraceEvents(filters = {}) {
|
|
|
5794
6063
|
[...args, limit, offset]
|
|
5795
6064
|
);
|
|
5796
6065
|
return { events, total };
|
|
5797
|
-
} catch {
|
|
5798
|
-
return { events: [], total: 0 };
|
|
5799
|
-
}
|
|
5800
|
-
}
|
|
5801
|
-
|
|
5802
|
-
// src/mcp/workflow-tools.ts
|
|
5803
|
-
import { v4 as uuid8 } from "uuid";
|
|
5804
|
-
|
|
5805
|
-
// src/shared/node-id.ts
|
|
5806
|
-
function assertSafeNodeId(rawId) {
|
|
5807
|
-
let id = rawId;
|
|
5808
|
-
try {
|
|
5809
|
-
id = decodeURIComponent(rawId);
|
|
5810
|
-
} catch {
|
|
5811
|
-
}
|
|
5812
|
-
if (!id || id.length > 512) {
|
|
5813
|
-
throw new ValidationError("invalid node id");
|
|
5814
|
-
}
|
|
5815
|
-
if (id.includes("\0") || id.includes("\\")) {
|
|
5816
|
-
throw new ValidationError("invalid node id");
|
|
5817
|
-
}
|
|
5818
|
-
if (id.startsWith("/")) {
|
|
5819
|
-
throw new ValidationError("invalid node id (absolute path)");
|
|
5820
|
-
}
|
|
5821
|
-
for (const seg of id.split("/")) {
|
|
5822
|
-
if (seg === "" || seg === "." || seg === "..") {
|
|
5823
|
-
throw new ValidationError("invalid node id (path traversal)");
|
|
5824
|
-
}
|
|
6066
|
+
} catch {
|
|
6067
|
+
return { events: [], total: 0 };
|
|
5825
6068
|
}
|
|
5826
|
-
return id;
|
|
5827
6069
|
}
|
|
5828
6070
|
|
|
6071
|
+
// src/mcp/workflow-tools.ts
|
|
6072
|
+
import { v4 as uuid8 } from "uuid";
|
|
6073
|
+
|
|
5829
6074
|
// src/workflow/run-service.ts
|
|
5830
6075
|
import { v4 as uuid6 } from "uuid";
|
|
5831
6076
|
var RUNNABLE_TYPES = ["agent", "skill"];
|
|
@@ -6221,6 +6466,18 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
|
|
|
6221
6466
|
}
|
|
6222
6467
|
}
|
|
6223
6468
|
}
|
|
6469
|
+
async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, excludeId) {
|
|
6470
|
+
const row = await db.get(
|
|
6471
|
+
`SELECT id FROM edges
|
|
6472
|
+
WHERE nest_id = ? AND from_node = ? AND to_node = ? AND type_id = ?` + (excludeId ? " AND id != ?" : ""),
|
|
6473
|
+
excludeId ? [nestId, fromNode, toNode, type.id, excludeId] : [nestId, fromNode, toNode, type.id]
|
|
6474
|
+
);
|
|
6475
|
+
if (row) {
|
|
6476
|
+
throw new ConflictError(
|
|
6477
|
+
`a "${type.name}" connection from "${fromNode}" to "${toNode}" already exists`
|
|
6478
|
+
);
|
|
6479
|
+
}
|
|
6480
|
+
}
|
|
6224
6481
|
var edgeRoutes = new Hono11();
|
|
6225
6482
|
edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
6226
6483
|
const nestId = c.req.param("nestId");
|
|
@@ -6291,6 +6548,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
|
6291
6548
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6292
6549
|
const id = uuid7();
|
|
6293
6550
|
await db.transaction(async (tx) => {
|
|
6551
|
+
await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
|
|
6294
6552
|
if (type.is_flow) {
|
|
6295
6553
|
await assertNoFlowCycle(tx, nestId, fromNode, toNode);
|
|
6296
6554
|
}
|
|
@@ -6318,6 +6576,8 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6318
6576
|
"SELECT * FROM edge_types WHERE id = ?",
|
|
6319
6577
|
[row.type_id]
|
|
6320
6578
|
);
|
|
6579
|
+
const wasFlow = !!type.is_flow;
|
|
6580
|
+
let retype = null;
|
|
6321
6581
|
if (typeof body.type === "string" && body.type.trim()) {
|
|
6322
6582
|
const next = await db.get(
|
|
6323
6583
|
"SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
|
|
@@ -6328,9 +6588,7 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6328
6588
|
`unknown edge type "${body.type}" \u2014 define it in the registry first`
|
|
6329
6589
|
);
|
|
6330
6590
|
}
|
|
6331
|
-
if (next.
|
|
6332
|
-
await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
|
|
6333
|
-
}
|
|
6591
|
+
if (next.id !== type.id) retype = next;
|
|
6334
6592
|
type = next;
|
|
6335
6593
|
}
|
|
6336
6594
|
let mode = row.condition_mode;
|
|
@@ -6344,11 +6602,19 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6344
6602
|
mode = v.mode;
|
|
6345
6603
|
stored = v.stored;
|
|
6346
6604
|
}
|
|
6347
|
-
await db.
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6605
|
+
await db.transaction(async (tx) => {
|
|
6606
|
+
if (retype) {
|
|
6607
|
+
await assertNoDuplicateEdge(tx, nestId, row.from_node, row.to_node, retype, id);
|
|
6608
|
+
if (retype.is_flow && !wasFlow) {
|
|
6609
|
+
await assertNoFlowCycle(tx, nestId, row.from_node, row.to_node);
|
|
6610
|
+
}
|
|
6611
|
+
}
|
|
6612
|
+
await tx.run(
|
|
6613
|
+
`UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
|
|
6614
|
+
WHERE id = ?`,
|
|
6615
|
+
[type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
|
|
6616
|
+
);
|
|
6617
|
+
});
|
|
6352
6618
|
const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
6353
6619
|
return c.json({ edge: toResponse(updated, type) });
|
|
6354
6620
|
});
|
|
@@ -6683,6 +6949,7 @@ async function createEdge(ctx, args) {
|
|
|
6683
6949
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6684
6950
|
const id = uuid8();
|
|
6685
6951
|
await db.transaction(async (tx) => {
|
|
6952
|
+
await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
|
|
6686
6953
|
if (type.is_flow) {
|
|
6687
6954
|
await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
|
|
6688
6955
|
}
|
|
@@ -6783,6 +7050,30 @@ function normalizeHops(raw) {
|
|
|
6783
7050
|
if (!Number.isFinite(n)) return 2;
|
|
6784
7051
|
return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
|
|
6785
7052
|
}
|
|
7053
|
+
function toolError(text, code) {
|
|
7054
|
+
return { text, isError: true, data: { code, message: text } };
|
|
7055
|
+
}
|
|
7056
|
+
function toCallToolResult(out) {
|
|
7057
|
+
if (typeof out === "string") {
|
|
7058
|
+
return { content: [{ type: "text", text: out }] };
|
|
7059
|
+
}
|
|
7060
|
+
return {
|
|
7061
|
+
content: [{ type: "text", text: out.text }],
|
|
7062
|
+
structuredContent: out.data,
|
|
7063
|
+
...out.isError ? { isError: true } : {}
|
|
7064
|
+
};
|
|
7065
|
+
}
|
|
7066
|
+
function summarizeNode(node, body) {
|
|
7067
|
+
return {
|
|
7068
|
+
id: node.id,
|
|
7069
|
+
title: node.frontmatter.title,
|
|
7070
|
+
description: node.frontmatter.description,
|
|
7071
|
+
type: node.frontmatter.type || "document",
|
|
7072
|
+
status: node.frontmatter.status || "draft",
|
|
7073
|
+
tags: node.frontmatter.tags,
|
|
7074
|
+
body: body ?? void 0
|
|
7075
|
+
};
|
|
7076
|
+
}
|
|
6786
7077
|
var TOOL_DEFINITIONS = [
|
|
6787
7078
|
{
|
|
6788
7079
|
name: "context_init",
|
|
@@ -6851,12 +7142,23 @@ var TOOL_DEFINITIONS = [
|
|
|
6851
7142
|
},
|
|
6852
7143
|
{
|
|
6853
7144
|
name: "context_list",
|
|
6854
|
-
description: "Browse vault contents with optional type, tag, or limit filters.",
|
|
7145
|
+
description: "Browse vault contents with optional type, tag, status, or limit filters.",
|
|
6855
7146
|
inputSchema: {
|
|
6856
7147
|
type: "object",
|
|
6857
7148
|
properties: {
|
|
6858
7149
|
type: { type: "string", description: "Filter by node type" },
|
|
6859
|
-
tag: {
|
|
7150
|
+
tag: {
|
|
7151
|
+
type: "string",
|
|
7152
|
+
description: "Filter by tag (leading # optional, case-insensitive)"
|
|
7153
|
+
},
|
|
7154
|
+
status: {
|
|
7155
|
+
type: "string",
|
|
7156
|
+
description: "Filter by status: draft | pending_review | approved | published | rejected. Aliases normalized. Unlike the CLI and the OSS server, retired nodes stay in this listing whether or not you ask \u2014 here they are unpublished documents their stewards may still act on."
|
|
7157
|
+
},
|
|
7158
|
+
approved_only: {
|
|
7159
|
+
type: "boolean",
|
|
7160
|
+
description: "Return only nodes with an approved version, each served at that version rather than the live file \u2014 the view a public nest gives a stranger."
|
|
7161
|
+
},
|
|
6860
7162
|
limit: { type: "number", description: "Max nodes to return" }
|
|
6861
7163
|
}
|
|
6862
7164
|
}
|
|
@@ -6909,7 +7211,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6909
7211
|
},
|
|
6910
7212
|
{
|
|
6911
7213
|
name: "context_create",
|
|
6912
|
-
description: "Create a new knowledge node in the vault.",
|
|
7214
|
+
description: "Create a new knowledge node in the vault. The title must not already be in use \u2014 these tools resolve documents by title, so a duplicate is refused rather than filed as an unreachable twin. To change an existing document use context_update; to refile it use context_move.",
|
|
6913
7215
|
inputSchema: {
|
|
6914
7216
|
type: "object",
|
|
6915
7217
|
properties: {
|
|
@@ -6930,7 +7232,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6930
7232
|
scope: { type: "string", description: "Visibility scope" },
|
|
6931
7233
|
folder: {
|
|
6932
7234
|
type: "string",
|
|
6933
|
-
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
7235
|
+
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified. A leading "nodes/" is accepted and not doubled, so "nodes/gtm/deals" and "gtm/deals" mean the same folder.'
|
|
6934
7236
|
}
|
|
6935
7237
|
},
|
|
6936
7238
|
required: ["title", "content"]
|
|
@@ -6938,13 +7240,20 @@ var TOOL_DEFINITIONS = [
|
|
|
6938
7240
|
},
|
|
6939
7241
|
{
|
|
6940
7242
|
name: "context_update",
|
|
6941
|
-
description: "Update an existing node \u2014 append,
|
|
7243
|
+
description: "Update an existing node \u2014 replace or append content, set tags, move it through its lifecycle. Whether the edit publishes is decided by this nest's stewardship rules, not by the caller.",
|
|
6942
7244
|
inputSchema: {
|
|
6943
7245
|
type: "object",
|
|
6944
7246
|
properties: {
|
|
6945
7247
|
title: {
|
|
7248
|
+
// Selects the node; it does NOT rename it. Titles are unique here
|
|
7249
|
+
// precisely so MCP callers can address a document this way. Renaming
|
|
7250
|
+
// is not reachable from this tool.
|
|
7251
|
+
type: "string",
|
|
7252
|
+
description: "Title of node to update (selects it; does not rename)"
|
|
7253
|
+
},
|
|
7254
|
+
id: {
|
|
6946
7255
|
type: "string",
|
|
6947
|
-
description: "
|
|
7256
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
6948
7257
|
},
|
|
6949
7258
|
content: {
|
|
6950
7259
|
type: "string",
|
|
@@ -6956,9 +7265,62 @@ var TOOL_DEFINITIONS = [
|
|
|
6956
7265
|
items: { type: "string" },
|
|
6957
7266
|
description: "Tags to add"
|
|
6958
7267
|
},
|
|
6959
|
-
|
|
7268
|
+
status: {
|
|
7269
|
+
type: "string",
|
|
7270
|
+
description: "New lifecycle status: draft | pending_review | approved | published | rejected. Aliases are normalized."
|
|
7271
|
+
},
|
|
7272
|
+
changeNote: {
|
|
7273
|
+
type: "string",
|
|
7274
|
+
description: "Why this changed \u2014 recorded in version history"
|
|
7275
|
+
}
|
|
7276
|
+
}
|
|
7277
|
+
// Neither is required alone — one of them is (the engine catalog's
|
|
7278
|
+
// `.refine(id || title)`, which JSON Schema can't express here).
|
|
7279
|
+
// `title` was required, which made this tool unreachable for `ctx`:
|
|
7280
|
+
// it addresses nodes by id and never sends a title.
|
|
7281
|
+
}
|
|
7282
|
+
},
|
|
7283
|
+
{
|
|
7284
|
+
name: "context_delete",
|
|
7285
|
+
// Mirrors the engine's canonical `context_delete` operation (core
|
|
7286
|
+
// namespace, alias delete_document): same name, same {id, title} input,
|
|
7287
|
+
// same "deleted" semantics. The engine's `uri` field is omitted — this
|
|
7288
|
+
// repo has no contextnest:// resolver on any surface, so accepting one
|
|
7289
|
+
// would promise a resolution it can't do.
|
|
7290
|
+
description: "Delete a node from the vault by title or id. Removes everything keyed to it: version history, review state, annotations, comments, watchers, schedules, trigger hooks, workflow edges, document grants, and any steward assignments scoped to it. Irreversible \u2014 the node must be recreated to come back, and it comes back clean: no history, no stewards, no shares. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
|
|
7291
|
+
inputSchema: {
|
|
7292
|
+
type: "object",
|
|
7293
|
+
properties: {
|
|
7294
|
+
title: { type: "string", description: "Title of node to delete" },
|
|
7295
|
+
id: {
|
|
7296
|
+
type: "string",
|
|
7297
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
7298
|
+
}
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
},
|
|
7302
|
+
{
|
|
7303
|
+
name: "context_move",
|
|
7304
|
+
// NOT in the engine's operation catalog — `move` is a community-edition
|
|
7305
|
+
// concern (the id rewrite spans governance tables the engine doesn't own).
|
|
7306
|
+
// This mirrors the existing REST POST /nodes/:id/move rather than adding a
|
|
7307
|
+
// new concept, so the surfaces agree. If move is ever promoted to a core
|
|
7308
|
+
// operation upstream, adopt that descriptor and drop this schema.
|
|
7309
|
+
description: "Move a node to a different folder. The id changes; version history, review state, annotations, comments, watchers, schedules, links, and document grants all follow. The title and its [[wiki-link]] stay valid. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
|
|
7310
|
+
inputSchema: {
|
|
7311
|
+
type: "object",
|
|
7312
|
+
properties: {
|
|
7313
|
+
title: { type: "string", description: "Title of node to move" },
|
|
7314
|
+
id: {
|
|
7315
|
+
type: "string",
|
|
7316
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
7317
|
+
},
|
|
7318
|
+
folder: {
|
|
7319
|
+
type: "string",
|
|
7320
|
+
description: 'Destination folder under nodes/ (e.g. "gtm/deals"); segments are slugified. Use "" to move to the nest root.'
|
|
7321
|
+
}
|
|
6960
7322
|
},
|
|
6961
|
-
required: ["
|
|
7323
|
+
required: ["folder"]
|
|
6962
7324
|
}
|
|
6963
7325
|
},
|
|
6964
7326
|
// ─── Governance Tools ───────────────────────────────────────────────
|
|
@@ -6975,6 +7337,73 @@ var TOOL_DEFINITIONS = [
|
|
|
6975
7337
|
}
|
|
6976
7338
|
}
|
|
6977
7339
|
},
|
|
7340
|
+
{
|
|
7341
|
+
// Deletion requests, mirroring the REST + UI flow: anyone who can READ a
|
|
7342
|
+
// thing may ask for it to be deleted; only an owner/admin resolves it.
|
|
7343
|
+
// Not in the engine catalog (1.2.1) — deletion requests live in this
|
|
7344
|
+
// repo's governance tables, which the engine doesn't own. Adopt an engine
|
|
7345
|
+
// descriptor here if the operation is ever promoted upstream.
|
|
7346
|
+
name: "context_request_deletion",
|
|
7347
|
+
description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or rejects it with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
|
|
7348
|
+
inputSchema: {
|
|
7349
|
+
type: "object",
|
|
7350
|
+
properties: {
|
|
7351
|
+
title: { type: "string", description: "Title of the document to flag" },
|
|
7352
|
+
id: {
|
|
7353
|
+
type: "string",
|
|
7354
|
+
description: 'Document id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two documents share a title.'
|
|
7355
|
+
},
|
|
7356
|
+
folder: {
|
|
7357
|
+
type: "string",
|
|
7358
|
+
description: 'Flag a whole folder instead of a document \u2014 the path, e.g. "gtm/deals". Every document under it goes if the request is honoured.'
|
|
7359
|
+
},
|
|
7360
|
+
nest: {
|
|
7361
|
+
type: "boolean",
|
|
7362
|
+
description: "Flag the ENTIRE nest for deletion. Only an owner/admin can honour it."
|
|
7363
|
+
},
|
|
7364
|
+
reason: {
|
|
7365
|
+
type: "string",
|
|
7366
|
+
description: "Why it should be deleted. Required \u2014 this is what the admin judges."
|
|
7367
|
+
}
|
|
7368
|
+
},
|
|
7369
|
+
required: ["reason"]
|
|
7370
|
+
}
|
|
7371
|
+
},
|
|
7372
|
+
{
|
|
7373
|
+
name: "context_deletion_queue",
|
|
7374
|
+
description: "Show deletion requests waiting on a nest owner/admin: what was flagged, who asked, and why. Use the request id with context_resolve_deletion to action one.",
|
|
7375
|
+
inputSchema: {
|
|
7376
|
+
type: "object",
|
|
7377
|
+
properties: {
|
|
7378
|
+
status: {
|
|
7379
|
+
type: "string",
|
|
7380
|
+
description: '"pending" (default) or "declined" \u2014 past rejections and their notes.'
|
|
7381
|
+
}
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
},
|
|
7385
|
+
{
|
|
7386
|
+
name: "context_resolve_deletion",
|
|
7387
|
+
description: "Action a deletion request: delete the flagged document/folder/nest, or reject it with a note explaining why it stays. Owner/admin only. Rejecting tells the requester the verdict; deleting is irreversible.",
|
|
7388
|
+
inputSchema: {
|
|
7389
|
+
type: "object",
|
|
7390
|
+
properties: {
|
|
7391
|
+
request_id: {
|
|
7392
|
+
type: "string",
|
|
7393
|
+
description: "Request id from context_deletion_queue"
|
|
7394
|
+
},
|
|
7395
|
+
action: {
|
|
7396
|
+
type: "string",
|
|
7397
|
+
description: '"delete" to honour it, "decline" to reject it and keep the target'
|
|
7398
|
+
},
|
|
7399
|
+
note: {
|
|
7400
|
+
type: "string",
|
|
7401
|
+
description: 'Why you rejected it. Required when action is "decline".'
|
|
7402
|
+
}
|
|
7403
|
+
},
|
|
7404
|
+
required: ["request_id", "action"]
|
|
7405
|
+
}
|
|
7406
|
+
},
|
|
6978
7407
|
{
|
|
6979
7408
|
name: "context_review_queue",
|
|
6980
7409
|
description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
|
|
@@ -6988,6 +7417,14 @@ var TOOL_DEFINITIONS = [
|
|
|
6988
7417
|
}
|
|
6989
7418
|
}
|
|
6990
7419
|
},
|
|
7420
|
+
{
|
|
7421
|
+
name: "context_my_drafts",
|
|
7422
|
+
description: "List YOUR unsubmitted drafts in this nest \u2014 documents you wrote that were never submitted for review (plus any that came back rejected). These are invisible to the review queue and unreadable by AI queries until submitted and approved. Use context_submit_review to hand one to a steward.",
|
|
7423
|
+
inputSchema: {
|
|
7424
|
+
type: "object",
|
|
7425
|
+
properties: {}
|
|
7426
|
+
}
|
|
7427
|
+
},
|
|
6991
7428
|
{
|
|
6992
7429
|
name: "context_submit_review",
|
|
6993
7430
|
description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
|
|
@@ -7031,9 +7468,13 @@ var TOOL_DEFINITIONS = [
|
|
|
7031
7468
|
inputSchema: {
|
|
7032
7469
|
type: "object",
|
|
7033
7470
|
properties: {
|
|
7034
|
-
title: { type: "string", description: "Title of the node" }
|
|
7035
|
-
|
|
7036
|
-
|
|
7471
|
+
title: { type: "string", description: "Title of the node" },
|
|
7472
|
+
id: {
|
|
7473
|
+
type: "string",
|
|
7474
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
7475
|
+
}
|
|
7476
|
+
}
|
|
7477
|
+
// One of id or title — see context_update.
|
|
7037
7478
|
}
|
|
7038
7479
|
},
|
|
7039
7480
|
{
|
|
@@ -7140,40 +7581,69 @@ async function handleToolCall(toolName, args, ctx) {
|
|
|
7140
7581
|
});
|
|
7141
7582
|
}
|
|
7142
7583
|
}
|
|
7584
|
+
async function resolveTargetNode(storage, args) {
|
|
7585
|
+
if (!args.id && !args.title) return "Provide a title or an id.";
|
|
7586
|
+
if (args.id) {
|
|
7587
|
+
try {
|
|
7588
|
+
return await storage.readDocument(assertSafeNodeId(args.id));
|
|
7589
|
+
} catch {
|
|
7590
|
+
return `Node not found: ${args.id}`;
|
|
7591
|
+
}
|
|
7592
|
+
}
|
|
7593
|
+
const docs = await storage.discoverDocuments();
|
|
7594
|
+
const matches = docs.filter(
|
|
7595
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7596
|
+
);
|
|
7597
|
+
if (matches.length > 1) {
|
|
7598
|
+
return `${matches.length} nodes share the title "${args.title}". Re-run with one of these ids instead: ${matches.map((n) => n.id).join(", ")}`;
|
|
7599
|
+
}
|
|
7600
|
+
return matches[0] ?? `Node not found: ${args.title}`;
|
|
7601
|
+
}
|
|
7143
7602
|
async function runTool(toolName, args, ctx) {
|
|
7144
7603
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
7145
7604
|
switch (toolName) {
|
|
7146
7605
|
case "context_init": {
|
|
7147
|
-
const
|
|
7148
|
-
|
|
7606
|
+
const { context_md } = await engineApi.run(
|
|
7607
|
+
"context_init",
|
|
7608
|
+
{},
|
|
7609
|
+
await opContext(nestId, userEmail)
|
|
7610
|
+
);
|
|
7611
|
+
if (!context_md)
|
|
7149
7612
|
return "No CONTEXT.md found. Use context_overview to see what's available.";
|
|
7150
7613
|
return `# Vault Instructions (CONTEXT.md)
|
|
7151
7614
|
|
|
7152
|
-
${
|
|
7615
|
+
${context_md}`;
|
|
7153
7616
|
}
|
|
7154
7617
|
case "context_overview": {
|
|
7155
|
-
const
|
|
7618
|
+
const { total } = await engineApi.run(
|
|
7619
|
+
"context_init",
|
|
7620
|
+
{},
|
|
7621
|
+
await opContext(nestId, userEmail)
|
|
7622
|
+
);
|
|
7623
|
+
const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
|
|
7156
7624
|
const types = {};
|
|
7157
7625
|
const tags = {};
|
|
7158
|
-
for (const n of
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
}
|
|
7163
|
-
const
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
|
|
7167
|
-
${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
|
|
7626
|
+
for (const n of visible) {
|
|
7627
|
+
types[n.type] = (types[n.type] || 0) + 1;
|
|
7628
|
+
for (const tag of n.tags || []) tags[tag] = (tags[tag] || 0) + 1;
|
|
7629
|
+
}
|
|
7630
|
+
const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
|
|
7631
|
+
const nodeList = visible.map(
|
|
7632
|
+
(n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).slice(0, 4).join(" ")}
|
|
7633
|
+
${(n.content || "").slice(0, 120).replace(/\n/g, " ")}`
|
|
7168
7634
|
).join("\n\n");
|
|
7635
|
+
const withheld = total - visible.length;
|
|
7636
|
+
const note = withheld > 0 ? `
|
|
7637
|
+
_${withheld} node(s) not visible to you._
|
|
7638
|
+
` : "";
|
|
7169
7639
|
return `# Vault Overview
|
|
7170
|
-
**Total nodes:** ${
|
|
7171
|
-
|
|
7640
|
+
**Total nodes:** ${visible.length}
|
|
7641
|
+
${note}
|
|
7172
7642
|
## Types
|
|
7173
|
-
${
|
|
7643
|
+
${fmt(types)}
|
|
7174
7644
|
|
|
7175
7645
|
## Tags
|
|
7176
|
-
${
|
|
7646
|
+
${fmt(tags)}
|
|
7177
7647
|
|
|
7178
7648
|
## All Nodes
|
|
7179
7649
|
|
|
@@ -7195,14 +7665,26 @@ ${nodeList}`;
|
|
|
7195
7665
|
].join(" ").toLowerCase();
|
|
7196
7666
|
return terms.every((t) => hay.includes(t));
|
|
7197
7667
|
});
|
|
7198
|
-
if (!matches.length)
|
|
7668
|
+
if (!matches.length)
|
|
7669
|
+
return {
|
|
7670
|
+
text: `No nodes matched search: "${args.query}"`,
|
|
7671
|
+
data: { results: [] }
|
|
7672
|
+
};
|
|
7199
7673
|
const results = matches.map(
|
|
7200
7674
|
({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
|
|
7201
7675
|
${(body || "").slice(0, 200).replace(/\n/g, " ")}`
|
|
7202
7676
|
).join("\n\n");
|
|
7203
|
-
return
|
|
7204
|
-
|
|
7205
|
-
|
|
7677
|
+
return {
|
|
7678
|
+
text: `Found ${matches.length} node(s) matching "${args.query}":
|
|
7679
|
+
|
|
7680
|
+
${results}`,
|
|
7681
|
+
// Same gated bodies the prose is built from — `resolveLlmBody`
|
|
7682
|
+
// already refused everything this caller may not read. No `score`:
|
|
7683
|
+
// this is a term-containment filter, not a ranked search.
|
|
7684
|
+
data: {
|
|
7685
|
+
results: matches.map(({ node: n, body }) => summarizeNode(n, body))
|
|
7686
|
+
}
|
|
7687
|
+
};
|
|
7206
7688
|
}
|
|
7207
7689
|
case "context_table_query": {
|
|
7208
7690
|
const result = await queryTableNode(
|
|
@@ -7219,61 +7701,119 @@ ${results}`;
|
|
|
7219
7701
|
${lines.join("\n")}`;
|
|
7220
7702
|
}
|
|
7221
7703
|
case "context_query": {
|
|
7222
|
-
const
|
|
7223
|
-
|
|
7224
|
-
|
|
7704
|
+
const { documents, traversal } = await engineApi.run(
|
|
7705
|
+
"context_query",
|
|
7706
|
+
{ query: args.query, hops: normalizeHops(args.hops) },
|
|
7707
|
+
await opContext(nestId, userEmail)
|
|
7708
|
+
);
|
|
7225
7709
|
const visibility = await Promise.all(
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7710
|
+
documents.map(async (d) => ({
|
|
7711
|
+
doc: d,
|
|
7712
|
+
body: await resolveLlmBody(ctx, {
|
|
7713
|
+
id: d.id,
|
|
7714
|
+
body: d.body ?? "",
|
|
7715
|
+
frontmatter: { title: d.title }
|
|
7716
|
+
})
|
|
7229
7717
|
}))
|
|
7230
7718
|
);
|
|
7231
|
-
const
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
${
|
|
7719
|
+
const visible = visibility.filter((e) => e.body !== null);
|
|
7720
|
+
const nodes = visible.map((e) => e.doc);
|
|
7721
|
+
if (!nodes.length)
|
|
7722
|
+
return {
|
|
7723
|
+
text: `No nodes matched: ${args.query}`,
|
|
7724
|
+
data: { documents: [], traversal }
|
|
7725
|
+
};
|
|
7726
|
+
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).join(" ")}`).join("\n\n");
|
|
7727
|
+
return {
|
|
7728
|
+
text: `Found ${nodes.length} node(s) for \`${args.query}\`:
|
|
7729
|
+
|
|
7730
|
+
${list}`,
|
|
7731
|
+
// The engine already hands back catalog `nodeSummary` shapes — only
|
|
7732
|
+
// `body` is swapped for the gated one resolveLlmBody returned.
|
|
7733
|
+
// `source_nodes` is deliberately absent: this handler never gates
|
|
7734
|
+
// them, so serving them would hand out content the prose refuses.
|
|
7735
|
+
data: {
|
|
7736
|
+
documents: visible.map((e) => ({ ...e.doc, body: e.body ?? void 0 })),
|
|
7737
|
+
traversal
|
|
7738
|
+
}
|
|
7739
|
+
};
|
|
7239
7740
|
}
|
|
7240
7741
|
case "context_get": {
|
|
7241
|
-
const docs = await storage.discoverDocuments();
|
|
7242
7742
|
let node;
|
|
7243
|
-
|
|
7244
|
-
node =
|
|
7245
|
-
|
|
7743
|
+
try {
|
|
7744
|
+
node = await getNodeForCaller(
|
|
7745
|
+
nestId,
|
|
7746
|
+
args.title ? { title: args.title } : { id: args.id },
|
|
7747
|
+
ctx.userId,
|
|
7748
|
+
userEmail
|
|
7749
|
+
);
|
|
7750
|
+
} catch {
|
|
7751
|
+
return toolError(
|
|
7752
|
+
`Node not found: ${args.title || args.id}`,
|
|
7753
|
+
"DOCUMENT_NOT_FOUND"
|
|
7246
7754
|
);
|
|
7247
|
-
} else if (args.id) {
|
|
7248
|
-
node = docs.find((n) => n.id === args.id);
|
|
7249
7755
|
}
|
|
7250
|
-
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
7251
7756
|
const body = await resolveLlmBody(ctx, node);
|
|
7252
7757
|
if (body === null) {
|
|
7253
|
-
return
|
|
7758
|
+
return toolError(
|
|
7759
|
+
`Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`,
|
|
7760
|
+
"UNAUTHORIZED_ACTION"
|
|
7761
|
+
);
|
|
7254
7762
|
}
|
|
7255
7763
|
const meta = [
|
|
7256
7764
|
`**Title:** ${node.frontmatter.title}`,
|
|
7257
7765
|
`**Type:** ${node.frontmatter.type || "document"}`,
|
|
7258
7766
|
node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
|
|
7259
7767
|
].filter(Boolean).join("\n");
|
|
7260
|
-
return
|
|
7768
|
+
return {
|
|
7769
|
+
text: `${meta}
|
|
7261
7770
|
|
|
7262
7771
|
---
|
|
7263
7772
|
|
|
7264
|
-
${body || "(no content)"}
|
|
7773
|
+
${body || "(no content)"}`,
|
|
7774
|
+
// `body` is the gated one, not node.body — under stewardship that is
|
|
7775
|
+
// the APPROVED snapshot, which can be older than the live draft this
|
|
7776
|
+
// frontmatter came from. So the payload carries only the fields the
|
|
7777
|
+
// prose above already reports, none of which claim to describe a
|
|
7778
|
+
// specific version. `version`, `status`, `checksum` and the
|
|
7779
|
+
// timestamps are deliberately absent: they describe the draft, and
|
|
7780
|
+
// pairing them with an approved body would misdescribe it (checksum
|
|
7781
|
+
// would also be a draft hash — see context_versions). `raw` is absent
|
|
7782
|
+
// too: no include_raw input here (Phase 2).
|
|
7783
|
+
data: {
|
|
7784
|
+
id: node.id,
|
|
7785
|
+
frontmatter: {
|
|
7786
|
+
title: node.frontmatter.title,
|
|
7787
|
+
description: node.frontmatter.description,
|
|
7788
|
+
type: node.frontmatter.type || "document",
|
|
7789
|
+
tags: node.frontmatter.tags
|
|
7790
|
+
},
|
|
7791
|
+
body
|
|
7792
|
+
}
|
|
7793
|
+
};
|
|
7265
7794
|
}
|
|
7266
7795
|
case "context_list": {
|
|
7267
7796
|
const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
|
|
7268
7797
|
type: args.type,
|
|
7269
7798
|
tag: args.tag,
|
|
7799
|
+
status: args.status,
|
|
7800
|
+
approvedOnly: args.approved_only === true,
|
|
7270
7801
|
limit: args.limit || 50
|
|
7271
7802
|
});
|
|
7272
|
-
|
|
7803
|
+
const documents = nodes.map((n) => ({
|
|
7804
|
+
id: n.id,
|
|
7805
|
+
title: n.title,
|
|
7806
|
+
description: n.description,
|
|
7807
|
+
type: n.type || "document",
|
|
7808
|
+
status: n.status || "draft",
|
|
7809
|
+
tags: n.tags
|
|
7810
|
+
}));
|
|
7811
|
+
if (!nodes.length)
|
|
7812
|
+
return { text: "No nodes found with the given filters.", data: { documents } };
|
|
7273
7813
|
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
|
|
7274
|
-
return `${nodes.length} node(s):
|
|
7814
|
+
return { text: `${nodes.length} node(s):
|
|
7275
7815
|
|
|
7276
|
-
${list}
|
|
7816
|
+
${list}`, data: { documents } };
|
|
7277
7817
|
}
|
|
7278
7818
|
case "context_resolve": {
|
|
7279
7819
|
const result = await queryEngine.query(args.selector, {
|
|
@@ -7371,43 +7911,125 @@ ${sections}`;
|
|
|
7371
7911
|
}
|
|
7372
7912
|
case "context_create": {
|
|
7373
7913
|
if (!await canCreateInNest(nestId, userEmail)) {
|
|
7374
|
-
return
|
|
7914
|
+
return toolError(
|
|
7915
|
+
"You don't have permission to create documents in this nest.",
|
|
7916
|
+
"UNAUTHORIZED_ACTION"
|
|
7917
|
+
);
|
|
7375
7918
|
}
|
|
7376
|
-
const
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7919
|
+
const wanted = String(args.title ?? "").toLowerCase();
|
|
7920
|
+
return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
|
|
7921
|
+
const existingDocs = await storage.discoverDocuments();
|
|
7922
|
+
const twin = existingDocs.find(
|
|
7923
|
+
(n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
|
|
7924
|
+
);
|
|
7925
|
+
if (twin) {
|
|
7926
|
+
return toolError(
|
|
7927
|
+
`A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`,
|
|
7928
|
+
"DOCUMENT_ALREADY_EXISTS"
|
|
7929
|
+
);
|
|
7930
|
+
}
|
|
7931
|
+
const { node, version } = await createNode(
|
|
7932
|
+
nestId,
|
|
7933
|
+
{
|
|
7934
|
+
title: args.title,
|
|
7935
|
+
content: args.content,
|
|
7936
|
+
type: args.type,
|
|
7937
|
+
tags: args.tags,
|
|
7938
|
+
scope: args.scope,
|
|
7939
|
+
folder: args.folder
|
|
7940
|
+
},
|
|
7941
|
+
userEmail
|
|
7942
|
+
);
|
|
7943
|
+
return {
|
|
7944
|
+
text: `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`,
|
|
7945
|
+
// checkpoint is null by construction: this server seals nothing —
|
|
7946
|
+
// a write becomes live through steward review, not a checkpoint.
|
|
7947
|
+
data: {
|
|
7948
|
+
id: node.id,
|
|
7949
|
+
version,
|
|
7950
|
+
status: node.frontmatter.status,
|
|
7951
|
+
checkpoint: null
|
|
7952
|
+
}
|
|
7953
|
+
};
|
|
7954
|
+
});
|
|
7389
7955
|
}
|
|
7390
7956
|
case "context_update": {
|
|
7391
|
-
const
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
);
|
|
7395
|
-
if (!node) return `Node not found: ${args.title}`;
|
|
7957
|
+
const node = await resolveTargetNode(storage, args);
|
|
7958
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
7959
|
+
const label = args.id ? node.frontmatter.title : args.title;
|
|
7396
7960
|
const editCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7397
7961
|
if (!editCheck.allowed) {
|
|
7398
|
-
return
|
|
7962
|
+
return toolError(
|
|
7963
|
+
`You don't have permission to edit "${label}": ${editCheck.reason}`,
|
|
7964
|
+
"UNAUTHORIZED_ACTION"
|
|
7965
|
+
);
|
|
7399
7966
|
}
|
|
7400
|
-
const
|
|
7967
|
+
const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
|
|
7968
|
+
const { node: updated, version } = await updateNode(
|
|
7401
7969
|
nestId,
|
|
7402
7970
|
node.id,
|
|
7403
7971
|
{
|
|
7404
7972
|
content: args.content,
|
|
7405
7973
|
append: args.append,
|
|
7406
|
-
tags:
|
|
7974
|
+
tags: mergedTags,
|
|
7975
|
+
status: args.status,
|
|
7976
|
+
changeNote: args.changeNote
|
|
7407
7977
|
},
|
|
7408
7978
|
userEmail
|
|
7409
7979
|
);
|
|
7410
|
-
return
|
|
7980
|
+
return {
|
|
7981
|
+
text: `Updated node: **${updated.frontmatter.title}** \u2014 v${version}, status: ${updated.frontmatter.status}`,
|
|
7982
|
+
// checkpoint null — see context_create.
|
|
7983
|
+
data: {
|
|
7984
|
+
id: updated.id,
|
|
7985
|
+
version,
|
|
7986
|
+
status: updated.frontmatter.status,
|
|
7987
|
+
checkpoint: null
|
|
7988
|
+
}
|
|
7989
|
+
};
|
|
7990
|
+
}
|
|
7991
|
+
case "context_delete": {
|
|
7992
|
+
const node = await resolveTargetNode(storage, args);
|
|
7993
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
7994
|
+
const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7995
|
+
if (!deleteCheck.allowed) {
|
|
7996
|
+
return toolError(
|
|
7997
|
+
`You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
|
|
7998
|
+
|
|
7999
|
+
Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`,
|
|
8000
|
+
"UNAUTHORIZED_ACTION"
|
|
8001
|
+
);
|
|
8002
|
+
}
|
|
8003
|
+
await deleteNode(nestId, node.id, userEmail);
|
|
8004
|
+
return {
|
|
8005
|
+
text: `Deleted node: **${node.frontmatter.title}** (${node.id})`,
|
|
8006
|
+
data: {
|
|
8007
|
+
id: node.id,
|
|
8008
|
+
title: node.frontmatter.title,
|
|
8009
|
+
deleted: true
|
|
8010
|
+
}
|
|
8011
|
+
};
|
|
8012
|
+
}
|
|
8013
|
+
case "context_move": {
|
|
8014
|
+
if (typeof args.folder !== "string") {
|
|
8015
|
+
return 'folder is required \u2014 a path like "gtm/deals", or "" for the nest root.';
|
|
8016
|
+
}
|
|
8017
|
+
const node = await resolveTargetNode(storage, args);
|
|
8018
|
+
if (typeof node === "string") return node;
|
|
8019
|
+
const moveCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
8020
|
+
if (!moveCheck.allowed) {
|
|
8021
|
+
return `You don't have permission to move "${node.frontmatter.title}": ${moveCheck.reason}`;
|
|
8022
|
+
}
|
|
8023
|
+
const { node: moved, previousId } = await moveNode(
|
|
8024
|
+
nestId,
|
|
8025
|
+
node.id,
|
|
8026
|
+
args.folder,
|
|
8027
|
+
userEmail
|
|
8028
|
+
);
|
|
8029
|
+
if (previousId === moved.id) {
|
|
8030
|
+
return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
|
|
8031
|
+
}
|
|
8032
|
+
return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
|
|
7411
8033
|
}
|
|
7412
8034
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
7413
8035
|
case "context_stewards": {
|
|
@@ -7448,6 +8070,126 @@ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ?
|
|
|
7448
8070
|
|
|
7449
8071
|
${sections}`;
|
|
7450
8072
|
}
|
|
8073
|
+
case "context_request_deletion": {
|
|
8074
|
+
const reason = typeof args.reason === "string" ? args.reason.trim() : "";
|
|
8075
|
+
if (!reason) {
|
|
8076
|
+
return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
|
|
8077
|
+
}
|
|
8078
|
+
if (args.nest === true) {
|
|
8079
|
+
const request2 = await requestDeletion({
|
|
8080
|
+
nestId,
|
|
8081
|
+
nodeId: nestId,
|
|
8082
|
+
targetType: "nest",
|
|
8083
|
+
requestedBy: userEmail,
|
|
8084
|
+
reason,
|
|
8085
|
+
baseUrl: ctx.baseUrl
|
|
8086
|
+
});
|
|
8087
|
+
return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or reject the request.
|
|
8088
|
+
Request id: \`${request2.id}\``;
|
|
8089
|
+
}
|
|
8090
|
+
if (typeof args.folder === "string" && args.folder.trim()) {
|
|
8091
|
+
const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
|
|
8092
|
+
const docs = await storage.discoverDocuments();
|
|
8093
|
+
const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
|
|
8094
|
+
if (inside.length === 0) {
|
|
8095
|
+
return `No folder "${folder}" \u2014 nothing is filed under it. Use context_list to see what exists.`;
|
|
8096
|
+
}
|
|
8097
|
+
const request2 = await requestDeletion({
|
|
8098
|
+
nestId,
|
|
8099
|
+
nodeId: folder,
|
|
8100
|
+
targetType: "folder",
|
|
8101
|
+
requestedBy: userEmail,
|
|
8102
|
+
reason,
|
|
8103
|
+
baseUrl: ctx.baseUrl
|
|
8104
|
+
});
|
|
8105
|
+
return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or reject the request.
|
|
8106
|
+
Request id: \`${request2.id}\``;
|
|
8107
|
+
}
|
|
8108
|
+
const node = await resolveTargetNode(storage, args);
|
|
8109
|
+
if (typeof node === "string") return node;
|
|
8110
|
+
if (!await canReadNode(nestId, node.id, userId, userEmail)) {
|
|
8111
|
+
return `Node not found: ${node.id}`;
|
|
8112
|
+
}
|
|
8113
|
+
const request = await requestDeletion({
|
|
8114
|
+
nestId,
|
|
8115
|
+
nodeId: node.id,
|
|
8116
|
+
targetType: "document",
|
|
8117
|
+
requestedBy: userEmail,
|
|
8118
|
+
reason,
|
|
8119
|
+
baseUrl: ctx.baseUrl
|
|
8120
|
+
});
|
|
8121
|
+
return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or reject the request \u2014 nothing is removed yet.
|
|
8122
|
+
Request id: \`${request.id}\``;
|
|
8123
|
+
}
|
|
8124
|
+
case "context_deletion_queue": {
|
|
8125
|
+
const status = args.status === "declined" ? "declined" : "pending";
|
|
8126
|
+
const requests = await listDeletionRequests({ nestId, status });
|
|
8127
|
+
if (requests.length === 0) {
|
|
8128
|
+
return status === "pending" ? "No deletion requests waiting. All caught up!" : "No rejected deletion requests.";
|
|
8129
|
+
}
|
|
8130
|
+
const list = requests.map((r, i) => {
|
|
8131
|
+
const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
|
|
8132
|
+
const verdict = r.status === "declined" ? `
|
|
8133
|
+
Rejected by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
|
|
8134
|
+
return `${i + 1}. **${kind}${r.title || r.nodeId}**
|
|
8135
|
+
Asked by ${r.requestedBy}: "${r.reason}"
|
|
8136
|
+
Request id: \`${r.id}\`${verdict}`;
|
|
8137
|
+
}).join("\n\n");
|
|
8138
|
+
return `# Deletion requests (${status})
|
|
8139
|
+
**${requests.length} item(s)**
|
|
8140
|
+
|
|
8141
|
+
${list}`;
|
|
8142
|
+
}
|
|
8143
|
+
case "context_resolve_deletion": {
|
|
8144
|
+
const requestId = typeof args.request_id === "string" ? args.request_id : "";
|
|
8145
|
+
const action = args.action === "delete" ? "delete" : args.action === "decline" ? "decline" : null;
|
|
8146
|
+
if (!requestId || !action) {
|
|
8147
|
+
return 'request_id and action are required \u2014 action is "delete" or "decline". Use context_deletion_queue to list request ids.';
|
|
8148
|
+
}
|
|
8149
|
+
const request = await getDeletionRequest(requestId);
|
|
8150
|
+
if (!request || request.nestId !== nestId || request.status !== "pending") {
|
|
8151
|
+
return `No pending deletion request with id ${requestId}.`;
|
|
8152
|
+
}
|
|
8153
|
+
const roles = await resolveUserRoles(nestId, userEmail);
|
|
8154
|
+
if (!roles.includes("owner") && !roles.includes("admin")) {
|
|
8155
|
+
return `You don't have permission to resolve deletion requests in this nest \u2014 only a nest owner or admin can. You can add to the discussion, but the verdict is theirs.`;
|
|
8156
|
+
}
|
|
8157
|
+
const label = await deletionTargetTitle(request);
|
|
8158
|
+
if (action === "decline") {
|
|
8159
|
+
const note = typeof args.note === "string" ? args.note.trim() : "";
|
|
8160
|
+
if (!note) {
|
|
8161
|
+
return "note is required when rejecting \u2014 the requester is told why it stays.";
|
|
8162
|
+
}
|
|
8163
|
+
await declineDeletion({
|
|
8164
|
+
nestId,
|
|
8165
|
+
id: requestId,
|
|
8166
|
+
declinedBy: userEmail,
|
|
8167
|
+
note,
|
|
8168
|
+
baseUrl: ctx.baseUrl
|
|
8169
|
+
});
|
|
8170
|
+
return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
|
|
8171
|
+
}
|
|
8172
|
+
if (request.targetType === "nest") {
|
|
8173
|
+
await deleteNest(nestId);
|
|
8174
|
+
} else if (request.targetType === "folder") {
|
|
8175
|
+
await deleteFolder(nestId, request.nodeId, userEmail);
|
|
8176
|
+
await dropDeletionRequest(request.id);
|
|
8177
|
+
} else {
|
|
8178
|
+
await deleteNode(nestId, request.nodeId, userEmail);
|
|
8179
|
+
}
|
|
8180
|
+
await notifyDeletionResolved({
|
|
8181
|
+
nestId,
|
|
8182
|
+
nodeId: request.nodeId,
|
|
8183
|
+
docTitle: label,
|
|
8184
|
+
outcome: "deleted",
|
|
8185
|
+
requestedBy: request.requestedBy,
|
|
8186
|
+
resolvedBy: userEmail,
|
|
8187
|
+
requestId: request.id,
|
|
8188
|
+
targetType: request.targetType,
|
|
8189
|
+
baseUrl: ctx.baseUrl
|
|
8190
|
+
});
|
|
8191
|
+
return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
|
|
8192
|
+
}
|
|
7451
8193
|
case "context_review_queue": {
|
|
7452
8194
|
const status = args.status || "pending";
|
|
7453
8195
|
const result = await getReviewQueue({
|
|
@@ -7466,19 +8208,47 @@ ${sections}`;
|
|
|
7466
8208
|
**${result.total} item(s)**
|
|
7467
8209
|
|
|
7468
8210
|
${list}`;
|
|
8211
|
+
}
|
|
8212
|
+
case "context_my_drafts": {
|
|
8213
|
+
const { drafts, total } = await listMyDrafts({
|
|
8214
|
+
authorEmail: userEmail,
|
|
8215
|
+
nestIds: [ctx.nestId],
|
|
8216
|
+
limit: 100
|
|
8217
|
+
});
|
|
8218
|
+
if (total === 0) {
|
|
8219
|
+
return "No unsubmitted drafts \u2014 everything you've written here is in review or approved.";
|
|
8220
|
+
}
|
|
8221
|
+
const list = drafts.map(
|
|
8222
|
+
(d, i) => `${i + 1}. **${d.title}** v${d.version} \u2014 ${d.status}
|
|
8223
|
+
Path: ${d.nodeId}
|
|
8224
|
+
Last edited: ${d.updatedAt}`
|
|
8225
|
+
).join("\n\n");
|
|
8226
|
+
const more = total > drafts.length ? `
|
|
8227
|
+
|
|
8228
|
+
_Showing the ${drafts.length} most recent of ${total}._` : "";
|
|
8229
|
+
return `# My Drafts
|
|
8230
|
+
**${total} unsubmitted document(s)**
|
|
8231
|
+
|
|
8232
|
+
${list}${more}
|
|
8233
|
+
|
|
8234
|
+
Submit one with \`context_submit_review\`.`;
|
|
7469
8235
|
}
|
|
7470
8236
|
case "context_submit_review": {
|
|
7471
8237
|
const docs = await storage.discoverDocuments();
|
|
7472
8238
|
const node = docs.find(
|
|
7473
8239
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7474
8240
|
);
|
|
7475
|
-
if (!node) return `Node not found: ${args.title}
|
|
8241
|
+
if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
|
|
7476
8242
|
const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
|
|
7477
8243
|
if (!submitCheck.allowed) {
|
|
7478
|
-
return
|
|
8244
|
+
return toolError(
|
|
8245
|
+
`You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
|
|
8246
|
+
"UNAUTHORIZED_ACTION"
|
|
8247
|
+
);
|
|
7479
8248
|
}
|
|
7480
8249
|
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
7481
|
-
if (currentVersion === 0)
|
|
8250
|
+
if (currentVersion === 0)
|
|
8251
|
+
return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
|
|
7482
8252
|
try {
|
|
7483
8253
|
const request = await submitForReview({
|
|
7484
8254
|
nestId: ctx.nestId,
|
|
@@ -7497,9 +8267,33 @@ ${list}`;
|
|
|
7497
8267
|
|
|
7498
8268
|
**Stewards who can review:**
|
|
7499
8269
|
${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
|
|
7500
|
-
return
|
|
8270
|
+
return {
|
|
8271
|
+
text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
|
|
8272
|
+
// No catalog shape to conform to — `context_submit_review` is a
|
|
8273
|
+
// governance-namespace operation and that namespace is declared but
|
|
8274
|
+
// unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
|
|
8275
|
+
// is the review state the handler already computed, and nothing
|
|
8276
|
+
// more: no `version`/`checkpoint` at the top level, because this
|
|
8277
|
+
// operation writes no new version and seals no checkpoint — it
|
|
8278
|
+
// points a review request at the version that already existed.
|
|
8279
|
+
// Built from the same gated values the prose above is built from
|
|
8280
|
+
// (`request`, `resolved`), never re-read past the permission check.
|
|
8281
|
+
data: {
|
|
8282
|
+
id: node.id,
|
|
8283
|
+
submitted: true,
|
|
8284
|
+
review: {
|
|
8285
|
+
id: request.id,
|
|
8286
|
+
version: request.version,
|
|
8287
|
+
status: request.status,
|
|
8288
|
+
priority: request.priority,
|
|
8289
|
+
requested_by: request.requestedBy,
|
|
8290
|
+
requested_at: request.requestedAt
|
|
8291
|
+
},
|
|
8292
|
+
reviewers: resolved.map((r) => r.steward.userEmail)
|
|
8293
|
+
}
|
|
8294
|
+
};
|
|
7501
8295
|
} catch (err) {
|
|
7502
|
-
return `Failed to submit: ${err.message}
|
|
8296
|
+
return toolError(`Failed to submit: ${err.message}`, "INTERNAL");
|
|
7503
8297
|
}
|
|
7504
8298
|
}
|
|
7505
8299
|
case "context_approve": {
|
|
@@ -7543,23 +8337,46 @@ Reason: ${args.note}`;
|
|
|
7543
8337
|
}
|
|
7544
8338
|
}
|
|
7545
8339
|
case "context_versions": {
|
|
7546
|
-
const
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
);
|
|
7550
|
-
if (!node) return `Node not found: ${args.title}`;
|
|
8340
|
+
const node = await resolveTargetNode(storage, args);
|
|
8341
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
8342
|
+
const label = args.id ? node.frontmatter.title : args.title;
|
|
7551
8343
|
const allVersions = await getVersions(ctx.nestId, node.id);
|
|
7552
8344
|
const approved = await getApprovedVersion(ctx.nestId, node.id);
|
|
7553
8345
|
if (allVersions.length === 0) {
|
|
7554
|
-
return
|
|
8346
|
+
return {
|
|
8347
|
+
text: `No version history for "${label}".`,
|
|
8348
|
+
data: { id: node.id, approved_version: approved ?? null, versions: [] }
|
|
8349
|
+
};
|
|
7555
8350
|
}
|
|
7556
8351
|
const list = allVersions.map(
|
|
7557
8352
|
(v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
|
|
7558
8353
|
).join("\n");
|
|
7559
|
-
return
|
|
8354
|
+
return {
|
|
8355
|
+
text: `# Version History: ${label}
|
|
7560
8356
|
**Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
|
|
7561
8357
|
|
|
7562
|
-
${list}
|
|
8358
|
+
${list}`,
|
|
8359
|
+
// Five catalog fields have no equivalent here and are omitted rather
|
|
8360
|
+
// than faked: `keyframe_interval` and `keyframe` (content lives on
|
|
8361
|
+
// disk — there is no keyframe+diff model), `chain_hash` (integrity is
|
|
8362
|
+
// server-side, not a per-version chain), `content_hash` (the column
|
|
8363
|
+
// exists but is conflict-detection state this tool has never served,
|
|
8364
|
+
// and it is ungated), and `published_at` (a version carries a
|
|
8365
|
+
// lifecycle status, not a publish timestamp). `status` and
|
|
8366
|
+
// `approved_version` are the community equivalent — the same two
|
|
8367
|
+
// facts the prose above reports.
|
|
8368
|
+
data: {
|
|
8369
|
+
id: node.id,
|
|
8370
|
+
approved_version: approved ?? null,
|
|
8371
|
+
versions: allVersions.map((v) => ({
|
|
8372
|
+
version: v.version,
|
|
8373
|
+
status: v.status,
|
|
8374
|
+
edited_by: v.editedBy,
|
|
8375
|
+
edited_at: v.editedAt,
|
|
8376
|
+
note: v.changeNote
|
|
8377
|
+
}))
|
|
8378
|
+
}
|
|
8379
|
+
};
|
|
7563
8380
|
}
|
|
7564
8381
|
case "context_assign_steward": {
|
|
7565
8382
|
const scope = args.scope;
|
|
@@ -7617,8 +8434,8 @@ ${list}`;
|
|
|
7617
8434
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
7618
8435
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
7619
8436
|
try {
|
|
7620
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
7621
|
-
const db = (await import("./client-
|
|
8437
|
+
const { createGrant: createGrant2 } = await import("./grants-service-I535QVTW.js");
|
|
8438
|
+
const db = (await import("./client-OBS5HOTA.js")).getDb();
|
|
7622
8439
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
7623
8440
|
const e = normalizeEmail2(String(args.email || ""));
|
|
7624
8441
|
if (!e) return "email is required.";
|
|
@@ -7711,7 +8528,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
7711
8528
|
}
|
|
7712
8529
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
7713
8530
|
const engine = await engineCache.get(nestId);
|
|
7714
|
-
const
|
|
8531
|
+
const out = await handleToolCall(tool.name, args, {
|
|
7715
8532
|
storage: engine.storage,
|
|
7716
8533
|
queryEngine: engine.query,
|
|
7717
8534
|
versionManager: engine.versions,
|
|
@@ -7720,7 +8537,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
7720
8537
|
userEmail,
|
|
7721
8538
|
baseUrl
|
|
7722
8539
|
});
|
|
7723
|
-
return
|
|
8540
|
+
return toCallToolResult(out);
|
|
7724
8541
|
});
|
|
7725
8542
|
}
|
|
7726
8543
|
return server;
|
|
@@ -8797,6 +9614,7 @@ import { Hono as Hono19 } from "hono";
|
|
|
8797
9614
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8798
9615
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
8799
9616
|
import { z as z2 } from "zod";
|
|
9617
|
+
var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
|
|
8800
9618
|
async function getUserEmail3(userId) {
|
|
8801
9619
|
const db = getDb();
|
|
8802
9620
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
@@ -8978,6 +9796,38 @@ var SERVER_TOOL_DEFINITIONS = [
|
|
|
8978
9796
|
},
|
|
8979
9797
|
required: ["nest", "selector"]
|
|
8980
9798
|
}
|
|
9799
|
+
},
|
|
9800
|
+
{
|
|
9801
|
+
name: "nest_create",
|
|
9802
|
+
description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
|
|
9803
|
+
inputSchema: {
|
|
9804
|
+
type: "object",
|
|
9805
|
+
properties: {
|
|
9806
|
+
name: { type: "string", description: "Nest name" },
|
|
9807
|
+
description: { type: "string", description: "Optional description" }
|
|
9808
|
+
},
|
|
9809
|
+
required: ["name"]
|
|
9810
|
+
}
|
|
9811
|
+
},
|
|
9812
|
+
{
|
|
9813
|
+
name: "context_create",
|
|
9814
|
+
description: "Create a new knowledge node (document) in a nest. Needs write permission on that nest. A `folder` path creates the folder implicitly \u2014 folders exist through the nodes placed in them.",
|
|
9815
|
+
inputSchema: {
|
|
9816
|
+
type: "object",
|
|
9817
|
+
properties: {
|
|
9818
|
+
...NEST_ARG,
|
|
9819
|
+
title: { type: "string", description: "Descriptive title" },
|
|
9820
|
+
content: { type: "string", description: "Markdown content body" },
|
|
9821
|
+
type: { type: "string", description: "Node type (default: document)" },
|
|
9822
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags" },
|
|
9823
|
+
scope: { type: "string", description: "Visibility scope" },
|
|
9824
|
+
folder: {
|
|
9825
|
+
type: "string",
|
|
9826
|
+
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
9827
|
+
}
|
|
9828
|
+
},
|
|
9829
|
+
required: ["nest", "title", "content"]
|
|
9830
|
+
}
|
|
8981
9831
|
}
|
|
8982
9832
|
];
|
|
8983
9833
|
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
@@ -8986,6 +9836,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
|
|
|
8986
9836
|
if (!nests.length) return "No nests accessible with these credentials.";
|
|
8987
9837
|
return renderNestIndexMarkdown(nests, baseUrl);
|
|
8988
9838
|
}
|
|
9839
|
+
if (SERVER_WRITE_TOOLS.has(toolName)) {
|
|
9840
|
+
if (isSuspended()) {
|
|
9841
|
+
const reason = getSuspensionReason();
|
|
9842
|
+
return `This server is suspended by PromptOwl; writes are blocked${reason ? ` \u2014 ${reason}` : ""}. Contact support@promptowl.ai.`;
|
|
9843
|
+
}
|
|
9844
|
+
if (!getCurrentLicense()?.valid) {
|
|
9845
|
+
return "This server has no valid PromptOwl license installed, so writes are disabled. Ask the server admin to install one (POST /license/install).";
|
|
9846
|
+
}
|
|
9847
|
+
}
|
|
9848
|
+
if (toolName === "nest_create") {
|
|
9849
|
+
if (nestScope) {
|
|
9850
|
+
return "This credential is scoped to a single nest and cannot create new ones.";
|
|
9851
|
+
}
|
|
9852
|
+
try {
|
|
9853
|
+
const nest2 = await createNest(
|
|
9854
|
+
userId,
|
|
9855
|
+
String(args.name ?? ""),
|
|
9856
|
+
args.description ? String(args.description) : void 0
|
|
9857
|
+
);
|
|
9858
|
+
return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
|
|
9859
|
+
} catch (err) {
|
|
9860
|
+
if (err instanceof ValidationError) return err.message;
|
|
9861
|
+
throw err;
|
|
9862
|
+
}
|
|
9863
|
+
}
|
|
8989
9864
|
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
8990
9865
|
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
8991
9866
|
if (!nest) return inaccessible;
|
|
@@ -9021,7 +9896,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
9021
9896
|
shape[key] = field;
|
|
9022
9897
|
}
|
|
9023
9898
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
9024
|
-
const
|
|
9899
|
+
const out = await handleServerToolCall(
|
|
9025
9900
|
tool.name,
|
|
9026
9901
|
args,
|
|
9027
9902
|
userId,
|
|
@@ -9029,7 +9904,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
9029
9904
|
nestScope,
|
|
9030
9905
|
baseUrl
|
|
9031
9906
|
);
|
|
9032
|
-
return
|
|
9907
|
+
return toCallToolResult(out);
|
|
9033
9908
|
});
|
|
9034
9909
|
}
|
|
9035
9910
|
return server;
|
|
@@ -9144,7 +10019,9 @@ Streamable HTTP, stateless. Two endpoints:
|
|
|
9144
10019
|
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
9145
10020
|
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
9146
10021
|
each takes a \`nest\` argument (id or exact name) plus the same
|
|
9147
|
-
selector/hops/max_tokens parameters as the REST API.
|
|
10022
|
+
selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
|
|
10023
|
+
\`nest_create\` (new nest owned by the caller) and \`context_create\`
|
|
10024
|
+
(new document in a writable nest; a \`folder\` path creates the folder).
|
|
9148
10025
|
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
9149
10026
|
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
9150
10027
|
|
|
@@ -9737,6 +10614,123 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
9737
10614
|
}
|
|
9738
10615
|
return c.json({ ...result, requests });
|
|
9739
10616
|
});
|
|
10617
|
+
function denyUnlessNestManager(c, what) {
|
|
10618
|
+
const permission = c.get("nestPermission");
|
|
10619
|
+
if (permission === "owner" || permission === "admin") return null;
|
|
10620
|
+
return c.json(
|
|
10621
|
+
{
|
|
10622
|
+
error: `Only the nest owner or an admin can delete ${what}. Request its deletion instead, or ask an admin to action it.`
|
|
10623
|
+
},
|
|
10624
|
+
403
|
|
10625
|
+
);
|
|
10626
|
+
}
|
|
10627
|
+
governanceRoutes.post("/delete-folder", async (c) => {
|
|
10628
|
+
const nestId = c.req.param("nestId");
|
|
10629
|
+
const denied = denyUnlessNestManager(c, "a folder");
|
|
10630
|
+
if (denied) return denied;
|
|
10631
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10632
|
+
if (!body.folder?.trim()) {
|
|
10633
|
+
throw new ValidationError("folder is required");
|
|
10634
|
+
}
|
|
10635
|
+
const deleted = await deleteFolder(
|
|
10636
|
+
nestId,
|
|
10637
|
+
body.folder.trim(),
|
|
10638
|
+
await getUserEmail4(c)
|
|
10639
|
+
);
|
|
10640
|
+
return c.json({ deleted: deleted.length, nodeIds: deleted });
|
|
10641
|
+
});
|
|
10642
|
+
governanceRoutes.post("/request-deletion", async (c) => {
|
|
10643
|
+
const nestId = c.req.param("nestId");
|
|
10644
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10645
|
+
const reason = body.reason?.trim();
|
|
10646
|
+
if (!reason) {
|
|
10647
|
+
throw new ValidationError("A reason for the deletion request is required");
|
|
10648
|
+
}
|
|
10649
|
+
if (body.targetType !== "folder" && body.targetType !== "nest") {
|
|
10650
|
+
throw new ValidationError(
|
|
10651
|
+
'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
|
|
10652
|
+
);
|
|
10653
|
+
}
|
|
10654
|
+
const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
|
|
10655
|
+
if (body.targetType === "folder" && !target) {
|
|
10656
|
+
throw new ValidationError("target (the folder path) is required");
|
|
10657
|
+
}
|
|
10658
|
+
if (body.targetType === "folder") {
|
|
10659
|
+
const { storage } = await engineCache.get(nestId);
|
|
10660
|
+
const docs = await storage.discoverDocuments();
|
|
10661
|
+
if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
|
|
10662
|
+
return c.json({ error: `Folder not found: ${target}` }, 404);
|
|
10663
|
+
}
|
|
10664
|
+
}
|
|
10665
|
+
const request = await requestDeletion({
|
|
10666
|
+
nestId,
|
|
10667
|
+
nodeId: target,
|
|
10668
|
+
targetType: body.targetType,
|
|
10669
|
+
requestedBy: await getUserEmail4(c),
|
|
10670
|
+
reason,
|
|
10671
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10672
|
+
});
|
|
10673
|
+
return c.json({ request }, 201);
|
|
10674
|
+
});
|
|
10675
|
+
governanceRoutes.get("/deletion-requests", async (c) => {
|
|
10676
|
+
const nestId = c.req.param("nestId");
|
|
10677
|
+
const raw = c.req.query("status");
|
|
10678
|
+
const status = raw === "declined" || raw === "all" ? raw : "pending";
|
|
10679
|
+
const requests = await listDeletionRequests({ nestId, status });
|
|
10680
|
+
return c.json({ requests, total: requests.length });
|
|
10681
|
+
});
|
|
10682
|
+
governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
|
|
10683
|
+
const nestId = c.req.param("nestId");
|
|
10684
|
+
const request = await getDeletionRequest(c.req.param("id"));
|
|
10685
|
+
if (!request || request.nestId !== nestId || request.status !== "pending") {
|
|
10686
|
+
return c.json({ error: "Deletion request not found" }, 404);
|
|
10687
|
+
}
|
|
10688
|
+
const actor = await getUserEmail4(c);
|
|
10689
|
+
const docTitle = await deletionTargetTitle(request);
|
|
10690
|
+
if (request.targetType === "nest" || request.targetType === "folder") {
|
|
10691
|
+
const denied = denyUnlessNestManager(
|
|
10692
|
+
c,
|
|
10693
|
+
request.targetType === "nest" ? "a nest" : "a folder"
|
|
10694
|
+
);
|
|
10695
|
+
if (denied) return denied;
|
|
10696
|
+
}
|
|
10697
|
+
if (request.targetType === "nest") {
|
|
10698
|
+
await deleteNest(nestId);
|
|
10699
|
+
} else if (request.targetType === "folder") {
|
|
10700
|
+
await deleteFolder(nestId, request.nodeId, actor);
|
|
10701
|
+
await dropDeletionRequest(request.id);
|
|
10702
|
+
} else {
|
|
10703
|
+
await deleteNode(nestId, request.nodeId, actor);
|
|
10704
|
+
}
|
|
10705
|
+
await notifyDeletionResolved({
|
|
10706
|
+
nestId,
|
|
10707
|
+
nodeId: request.nodeId,
|
|
10708
|
+
docTitle,
|
|
10709
|
+
outcome: "deleted",
|
|
10710
|
+
requestedBy: request.requestedBy,
|
|
10711
|
+
resolvedBy: actor,
|
|
10712
|
+
requestId: request.id,
|
|
10713
|
+
targetType: request.targetType,
|
|
10714
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10715
|
+
});
|
|
10716
|
+
return c.json({ deleted: true, nodeId: request.nodeId });
|
|
10717
|
+
});
|
|
10718
|
+
governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
|
|
10719
|
+
const nestId = c.req.param("nestId");
|
|
10720
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10721
|
+
if (!body.note?.trim()) {
|
|
10722
|
+
throw new ValidationError("A note is required when declining");
|
|
10723
|
+
}
|
|
10724
|
+
const request = await declineDeletion({
|
|
10725
|
+
nestId,
|
|
10726
|
+
id: c.req.param("id"),
|
|
10727
|
+
declinedBy: await getUserEmail4(c),
|
|
10728
|
+
note: body.note.trim(),
|
|
10729
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10730
|
+
});
|
|
10731
|
+
if (!request) return c.json({ error: "Deletion request not found" }, 404);
|
|
10732
|
+
return c.json({ request });
|
|
10733
|
+
});
|
|
9740
10734
|
governanceRoutes.get("/feed", async (c) => {
|
|
9741
10735
|
const nestId = c.req.param("nestId");
|
|
9742
10736
|
const filterRaw = c.req.query("filter");
|
|
@@ -9798,25 +10792,78 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
9798
10792
|
governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
9799
10793
|
const nestId = c.req.param("nestId");
|
|
9800
10794
|
const nodeId = c.req.param("nodeId");
|
|
10795
|
+
const userId = c.get("userId");
|
|
10796
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
10797
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
10798
|
+
return c.json(
|
|
10799
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
10800
|
+
403
|
|
10801
|
+
);
|
|
10802
|
+
}
|
|
9801
10803
|
const allVersions = await getVersions(nestId, nodeId);
|
|
9802
10804
|
const approved = await getApprovedVersion(nestId, nodeId);
|
|
9803
10805
|
const { versions: versionManager } = await engineCache.get(nestId);
|
|
9804
|
-
const
|
|
10806
|
+
const resolutions = await getDb().all(
|
|
10807
|
+
`SELECT version, status, resolved_by
|
|
10808
|
+
FROM review_requests
|
|
10809
|
+
WHERE nest_id = ? AND node_id = ?
|
|
10810
|
+
AND status IN ('approved', 'rejected')
|
|
10811
|
+
AND resolved_by IS NOT NULL
|
|
10812
|
+
ORDER BY resolved_at DESC`,
|
|
10813
|
+
[nestId, nodeId]
|
|
10814
|
+
);
|
|
10815
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
10816
|
+
for (const r of resolutions) {
|
|
10817
|
+
if (!byVersion.has(r.version)) {
|
|
10818
|
+
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
10819
|
+
}
|
|
10820
|
+
}
|
|
10821
|
+
const enriched = await Promise.all(
|
|
9805
10822
|
allVersions.map(async (v) => {
|
|
10823
|
+
const r = byVersion.get(v.version);
|
|
10824
|
+
const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
|
|
9806
10825
|
try {
|
|
9807
|
-
const
|
|
9808
|
-
return { ...v,
|
|
10826
|
+
const diff = await versionManager.getDiff(nodeId, v.version);
|
|
10827
|
+
return { ...v, ...resolution, diff: diff ?? void 0 };
|
|
9809
10828
|
} catch {
|
|
9810
|
-
return v;
|
|
10829
|
+
return { ...v, ...resolution };
|
|
9811
10830
|
}
|
|
9812
10831
|
})
|
|
9813
10832
|
);
|
|
9814
10833
|
return c.json({
|
|
9815
|
-
versions:
|
|
10834
|
+
versions: enriched,
|
|
9816
10835
|
approvedVersion: approved,
|
|
9817
|
-
currentVersion: allVersions[0]?.version || 0
|
|
10836
|
+
currentVersion: allVersions[0]?.version || 0,
|
|
10837
|
+
// Verdicts on out-of-band edits. A rejection commits no content, so it has
|
|
10838
|
+
// no version row to hang off — it lives in the engine's chain-event log and
|
|
10839
|
+
// rides alongside the version list rather than inside it.
|
|
10840
|
+
externalEditVerdicts: await listExternalEditVerdicts(
|
|
10841
|
+
nestId,
|
|
10842
|
+
nodeId
|
|
10843
|
+
)
|
|
9818
10844
|
});
|
|
9819
10845
|
});
|
|
10846
|
+
governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
|
|
10847
|
+
const nestId = c.req.param("nestId");
|
|
10848
|
+
const nodeId = c.req.param("nodeId");
|
|
10849
|
+
const version = Number(c.req.param("version"));
|
|
10850
|
+
const userId = c.get("userId");
|
|
10851
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
10852
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
10853
|
+
return c.json(
|
|
10854
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
10855
|
+
403
|
|
10856
|
+
);
|
|
10857
|
+
}
|
|
10858
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
10859
|
+
try {
|
|
10860
|
+
const raw = await versionManager.reconstructVersion(nodeId, version);
|
|
10861
|
+
return c.json({ version, content: bodyOnly(nodeId, raw) });
|
|
10862
|
+
} catch (err) {
|
|
10863
|
+
console.error("reconstructVersion failed", nestId, nodeId, version, err);
|
|
10864
|
+
return c.json({ version, content: null }, 200);
|
|
10865
|
+
}
|
|
10866
|
+
});
|
|
9820
10867
|
governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
9821
10868
|
const nestId = c.req.param("nestId");
|
|
9822
10869
|
const nodeId = c.req.param("nodeId");
|
|
@@ -9929,6 +10976,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
9929
10976
|
201
|
|
9930
10977
|
);
|
|
9931
10978
|
});
|
|
10979
|
+
governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
|
|
10980
|
+
const nestId = c.req.param("nestId");
|
|
10981
|
+
const nodeId = c.req.param("nodeId");
|
|
10982
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10983
|
+
const reason = body.reason?.trim();
|
|
10984
|
+
if (!reason) {
|
|
10985
|
+
throw new ValidationError("A reason for the deletion request is required");
|
|
10986
|
+
}
|
|
10987
|
+
const userEmail = await getUserEmail4(c);
|
|
10988
|
+
if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
|
|
10989
|
+
return c.json({ error: `Node not found: ${nodeId}` }, 404);
|
|
10990
|
+
}
|
|
10991
|
+
const { storage } = await engineCache.get(nestId);
|
|
10992
|
+
try {
|
|
10993
|
+
await storage.readDocument(nodeId);
|
|
10994
|
+
} catch {
|
|
10995
|
+
return c.json({ error: `Node not found: ${nodeId}` }, 404);
|
|
10996
|
+
}
|
|
10997
|
+
const request = await requestDeletion({
|
|
10998
|
+
nestId,
|
|
10999
|
+
nodeId,
|
|
11000
|
+
requestedBy: userEmail,
|
|
11001
|
+
reason,
|
|
11002
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
11003
|
+
});
|
|
11004
|
+
return c.json({ request }, 201);
|
|
11005
|
+
});
|
|
9932
11006
|
governanceNodeRoutes.post(
|
|
9933
11007
|
"/:nodeId{.+}/external-edits/:suggestionId/approve",
|
|
9934
11008
|
async (c) => {
|
|
@@ -9948,7 +11022,9 @@ governanceNodeRoutes.post(
|
|
|
9948
11022
|
return c.json({
|
|
9949
11023
|
approved: true,
|
|
9950
11024
|
version: result.versionEntry.version,
|
|
9951
|
-
chainEvent: result.chainEvent.event_id
|
|
11025
|
+
chainEvent: result.chainEvent.event_id,
|
|
11026
|
+
// As on reject: false means the verdict never reached the audit log.
|
|
11027
|
+
historyRecorded: result.historyRecorded
|
|
9952
11028
|
});
|
|
9953
11029
|
} catch (err) {
|
|
9954
11030
|
console.error(
|
|
@@ -9983,7 +11059,10 @@ governanceNodeRoutes.post(
|
|
|
9983
11059
|
});
|
|
9984
11060
|
return c.json({
|
|
9985
11061
|
rejected: true,
|
|
9986
|
-
chainEvent: result.chainEvent.event_id
|
|
11062
|
+
chainEvent: result.chainEvent.event_id,
|
|
11063
|
+
// False means the edit was rejected but the audit entry did not land —
|
|
11064
|
+
// the caller is not shown a clean success (server log carries why).
|
|
11065
|
+
historyRecorded: result.historyRecorded
|
|
9987
11066
|
});
|
|
9988
11067
|
} catch (err) {
|
|
9989
11068
|
return c.json({ error: err.message }, 400);
|
|
@@ -10162,6 +11241,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
10162
11241
|
}
|
|
10163
11242
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
10164
11243
|
});
|
|
11244
|
+
function nestPathParts(path) {
|
|
11245
|
+
return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
|
|
11246
|
+
}
|
|
11247
|
+
var flexAuthWithPromptowlTicket = createMiddleware2(
|
|
11248
|
+
async (c, next) => {
|
|
11249
|
+
const header = c.req.header("Authorization") || "";
|
|
11250
|
+
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
|
|
11251
|
+
const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
|
|
11252
|
+
const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
|
|
11253
|
+
if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
|
|
11254
|
+
let claims;
|
|
11255
|
+
try {
|
|
11256
|
+
claims = verifySsoTicket(token, secret);
|
|
11257
|
+
} catch {
|
|
11258
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
11259
|
+
}
|
|
11260
|
+
if (claims.scope !== "mcp" || claims.jti) {
|
|
11261
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
11262
|
+
}
|
|
11263
|
+
const expectedAud = config.PUBLIC_BASE_URL;
|
|
11264
|
+
if (expectedAud && (claims.aud || "").replace(/\/$/, "") !== expectedAud) {
|
|
11265
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
11266
|
+
}
|
|
11267
|
+
const sub = claims.sub.trim();
|
|
11268
|
+
if (!isValidTicketSub(sub)) {
|
|
11269
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
11270
|
+
}
|
|
11271
|
+
const db = getDb();
|
|
11272
|
+
const user = await db.get(
|
|
11273
|
+
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
11274
|
+
[sub]
|
|
11275
|
+
);
|
|
11276
|
+
if (!user) {
|
|
11277
|
+
return c.json(
|
|
11278
|
+
{ error: "No ContextNest account exists for this PromptOwl user" },
|
|
11279
|
+
404
|
|
11280
|
+
);
|
|
11281
|
+
}
|
|
11282
|
+
c.set("userId", user.id);
|
|
11283
|
+
c.set("nestScope", null);
|
|
11284
|
+
return next();
|
|
11285
|
+
}
|
|
11286
|
+
);
|
|
10165
11287
|
var APPROVE_CACHE_TTL_MS = 3e4;
|
|
10166
11288
|
var approveCache = /* @__PURE__ */ new Map();
|
|
10167
11289
|
async function canApproveInNest(nestId, email) {
|
|
@@ -10185,6 +11307,13 @@ function createApp() {
|
|
|
10185
11307
|
"*",
|
|
10186
11308
|
corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
|
|
10187
11309
|
);
|
|
11310
|
+
const frameAncestors = `frame-ancestors ${config.FRAME_ANCESTORS}`;
|
|
11311
|
+
app.use("*", async (c, next) => {
|
|
11312
|
+
await next();
|
|
11313
|
+
const existing = c.res.headers.get("Content-Security-Policy");
|
|
11314
|
+
const merged = mergeFrameAncestors(existing, frameAncestors);
|
|
11315
|
+
if (merged !== existing) c.header("Content-Security-Policy", merged);
|
|
11316
|
+
});
|
|
10188
11317
|
const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
|
|
10189
11318
|
app.use("*", async (c, next) => {
|
|
10190
11319
|
const lenStr = c.req.header("Content-Length");
|
|
@@ -10837,6 +11966,25 @@ function createApp() {
|
|
|
10837
11966
|
requested_at: r.requestedAt,
|
|
10838
11967
|
priority: r.priority
|
|
10839
11968
|
});
|
|
11969
|
+
const deletions = await listDeletionRequests({
|
|
11970
|
+
nestIds: [...nestName2.keys()],
|
|
11971
|
+
status: "pending"
|
|
11972
|
+
});
|
|
11973
|
+
const toDeletionItem = (r) => ({
|
|
11974
|
+
type: "deletion",
|
|
11975
|
+
nest_id: r.nestId,
|
|
11976
|
+
nest_name: nestName2.get(r.nestId),
|
|
11977
|
+
node_id: r.nodeId,
|
|
11978
|
+
title: r.title || r.nodeId,
|
|
11979
|
+
requested_by: r.requestedBy,
|
|
11980
|
+
requested_at: r.createdAt,
|
|
11981
|
+
priority: "normal",
|
|
11982
|
+
reason: r.reason,
|
|
11983
|
+
request_id: r.id,
|
|
11984
|
+
// node_id holds a folder path / nest id for those targets, so a client
|
|
11985
|
+
// must not treat it as a document to open.
|
|
11986
|
+
target_type: r.targetType
|
|
11987
|
+
});
|
|
10840
11988
|
const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
|
|
10841
11989
|
const others = requests.filter((r) => r.requestedBy !== me);
|
|
10842
11990
|
const nestApprove = new Map(
|
|
@@ -10849,8 +11997,27 @@ function createApp() {
|
|
|
10849
11997
|
)
|
|
10850
11998
|
)
|
|
10851
11999
|
);
|
|
10852
|
-
const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem)
|
|
10853
|
-
const waiting = requests.filter((r) => r.requestedBy === me).map(toItem)
|
|
12000
|
+
const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem);
|
|
12001
|
+
const waiting = requests.filter((r) => r.requestedBy === me).map(toItem);
|
|
12002
|
+
const othersDeletions = deletions.filter((r) => r.requestedBy !== me);
|
|
12003
|
+
const nestManage = new Map(
|
|
12004
|
+
await Promise.all(
|
|
12005
|
+
[...new Set(othersDeletions.map((r) => r.nestId))].map(
|
|
12006
|
+
async (nestId) => {
|
|
12007
|
+
const roles = await resolveUserRoles(nestId, me);
|
|
12008
|
+
return [nestId, roles.includes("owner") || roles.includes("admin")];
|
|
12009
|
+
}
|
|
12010
|
+
)
|
|
12011
|
+
)
|
|
12012
|
+
);
|
|
12013
|
+
items.push(
|
|
12014
|
+
...othersDeletions.filter((r) => nestManage.get(r.nestId)).map(toDeletionItem)
|
|
12015
|
+
);
|
|
12016
|
+
items.sort(byNewest);
|
|
12017
|
+
waiting.push(
|
|
12018
|
+
...deletions.filter((r) => r.requestedBy === me).map(toDeletionItem)
|
|
12019
|
+
);
|
|
12020
|
+
waiting.sort(byNewest);
|
|
10854
12021
|
const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
|
|
10855
12022
|
return c.json({
|
|
10856
12023
|
items,
|
|
@@ -10861,6 +12028,37 @@ function createApp() {
|
|
|
10861
12028
|
pagination: { total, limit, offset, has_more: offset + requests.length < total }
|
|
10862
12029
|
});
|
|
10863
12030
|
});
|
|
12031
|
+
app.get("/me/drafts", async (c) => {
|
|
12032
|
+
const userId = c.get("userId");
|
|
12033
|
+
const me = await resolveCallerEmail(userId);
|
|
12034
|
+
const nestName2 = /* @__PURE__ */ new Map();
|
|
12035
|
+
for (const n of await listVisibleNests(userId))
|
|
12036
|
+
nestName2.set(n.id, n.name ?? n.id);
|
|
12037
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
|
|
12038
|
+
const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
|
|
12039
|
+
const { drafts, total } = await listMyDrafts({
|
|
12040
|
+
authorEmail: me,
|
|
12041
|
+
nestIds: [...nestName2.keys()],
|
|
12042
|
+
limit,
|
|
12043
|
+
offset
|
|
12044
|
+
});
|
|
12045
|
+
return c.json({
|
|
12046
|
+
drafts: drafts.map((d) => ({
|
|
12047
|
+
nest_id: d.nestId,
|
|
12048
|
+
nest_name: nestName2.get(d.nestId),
|
|
12049
|
+
node_id: d.nodeId,
|
|
12050
|
+
title: d.title,
|
|
12051
|
+
// Where it actually lives — the thing that was impossible to find.
|
|
12052
|
+
folder: folderForNode(d.nodeId),
|
|
12053
|
+
status: d.status,
|
|
12054
|
+
version: d.version,
|
|
12055
|
+
updated_at: d.updatedAt
|
|
12056
|
+
})),
|
|
12057
|
+
// has_more off the REQUESTED page size, not the returned row count: the
|
|
12058
|
+
// existence filter can shorten a page, and that must not read as "last".
|
|
12059
|
+
pagination: { total, limit, offset, has_more: offset + limit < total }
|
|
12060
|
+
});
|
|
12061
|
+
});
|
|
10864
12062
|
app.get("/me/notifications", async (c) => {
|
|
10865
12063
|
const me = await resolveCallerEmail(c.get("userId"));
|
|
10866
12064
|
const unreadOnly = c.req.query("unread") === "true";
|
|
@@ -10896,19 +12094,54 @@ function createApp() {
|
|
|
10896
12094
|
users: usersRow.c
|
|
10897
12095
|
});
|
|
10898
12096
|
});
|
|
12097
|
+
app.use("/search", flexAuthMiddleware);
|
|
12098
|
+
app.get("/search", async (c) => {
|
|
12099
|
+
const q = (c.req.query("q") || "").trim();
|
|
12100
|
+
if (!q) throw new ValidationError("q query parameter is required");
|
|
12101
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
|
|
12102
|
+
const userId = c.get("userId");
|
|
12103
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
12104
|
+
const perNest = await Promise.all(
|
|
12105
|
+
(await listVisibleNests(userId)).map(async (nest) => {
|
|
12106
|
+
try {
|
|
12107
|
+
const { storage } = await engineCache.get(nest.id);
|
|
12108
|
+
const matches = matchDocuments(await storage.discoverDocuments(), q);
|
|
12109
|
+
if (matches.length === 0) return [];
|
|
12110
|
+
const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
|
|
12111
|
+
return accessible.map((n) => ({
|
|
12112
|
+
...toSearchHit(n),
|
|
12113
|
+
nest_id: nest.id,
|
|
12114
|
+
nest_name: nest.name ?? nest.id
|
|
12115
|
+
}));
|
|
12116
|
+
} catch (e) {
|
|
12117
|
+
console.error("[search] skipped nest", nest.id, e);
|
|
12118
|
+
return [];
|
|
12119
|
+
}
|
|
12120
|
+
})
|
|
12121
|
+
);
|
|
12122
|
+
const hits = perNest.flat();
|
|
12123
|
+
return c.json({
|
|
12124
|
+
query: q,
|
|
12125
|
+
count: hits.length,
|
|
12126
|
+
nodes: hits.slice(0, limit)
|
|
12127
|
+
});
|
|
12128
|
+
});
|
|
10899
12129
|
app.use("/runs/*", flexAuthMiddleware);
|
|
10900
12130
|
app.route("/runs", runDetailRoutes);
|
|
10901
12131
|
app.route("/hooks", hookFireRoutes);
|
|
10902
12132
|
app.route("/llms.txt", llmsTxtRoutes);
|
|
10903
|
-
app.use("/index",
|
|
12133
|
+
app.use("/index", flexAuthWithPromptowlTicket);
|
|
10904
12134
|
app.route("/index", nestIndexRoutes);
|
|
10905
|
-
app.use("/mcp",
|
|
12135
|
+
app.use("/mcp", flexAuthWithPromptowlTicket);
|
|
10906
12136
|
app.route("/mcp", serverMcpRoutes);
|
|
10907
12137
|
const nestsApp = new Hono21();
|
|
10908
|
-
nestsApp.use("*", flexAuthMiddleware);
|
|
10909
12138
|
nestsApp.use("*", async (c, next) => {
|
|
10910
|
-
const
|
|
10911
|
-
const
|
|
12139
|
+
const parts = nestPathParts(c.req.path);
|
|
12140
|
+
const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
|
|
12141
|
+
return ticketEligible ? flexAuthWithPromptowlTicket(c, next) : flexAuthMiddleware(c, next);
|
|
12142
|
+
});
|
|
12143
|
+
nestsApp.use("*", async (c, next) => {
|
|
12144
|
+
const parts = nestPathParts(c.req.path);
|
|
10912
12145
|
if (parts.length < 2) return next();
|
|
10913
12146
|
if (parts[0] === "unsynced") return next();
|
|
10914
12147
|
const nestId = parts[0];
|
|
@@ -10945,7 +12178,8 @@ function createApp() {
|
|
|
10945
12178
|
const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
|
|
10946
12179
|
const isActivityPath = /\/activity$/.test(path2);
|
|
10947
12180
|
const isFeedPath = parts.length === 2 && parts[1] === "feed";
|
|
10948
|
-
const
|
|
12181
|
+
const isDeletionQueuePath = parts.length === 2 && parts[1] === "deletion-requests";
|
|
12182
|
+
const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath || isFeedPath || isDeletionQueuePath;
|
|
10949
12183
|
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
10950
12184
|
if (needsLicense) {
|
|
10951
12185
|
const lic = getCurrentLicense();
|
|
@@ -10991,6 +12225,7 @@ function createApp() {
|
|
|
10991
12225
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
10992
12226
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
10993
12227
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
12228
|
+
const isDeletionRequestAction = /\/request-deletion$/.test(path);
|
|
10994
12229
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
10995
12230
|
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
10996
12231
|
return c.json(
|
|
@@ -11009,7 +12244,7 @@ function createApp() {
|
|
|
11009
12244
|
required = c.req.method === "GET" ? "write" : "admin";
|
|
11010
12245
|
} else if (parts[1] === "hooks") {
|
|
11011
12246
|
required = "write";
|
|
11012
|
-
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
12247
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
11013
12248
|
required = "write";
|
|
11014
12249
|
}
|
|
11015
12250
|
const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
|
|
@@ -11102,6 +12337,7 @@ function createApp() {
|
|
|
11102
12337
|
nestsApp.route("/:nestId/assets", assetRoutes);
|
|
11103
12338
|
nestsApp.route("/:nestId/mcp", mcpRoutes);
|
|
11104
12339
|
app.route("/nests", nestsApp);
|
|
12340
|
+
app.all("/.well-known/*", (c) => c.json({ error: "Not found" }, 404));
|
|
11105
12341
|
app.use(
|
|
11106
12342
|
"/assets/*",
|
|
11107
12343
|
serveStatic({
|
|
@@ -11109,14 +12345,20 @@ function createApp() {
|
|
|
11109
12345
|
onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
|
|
11110
12346
|
})
|
|
11111
12347
|
);
|
|
12348
|
+
const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
|
|
11112
12349
|
app.get(
|
|
11113
12350
|
"*",
|
|
12351
|
+
async (c, next) => {
|
|
12352
|
+
if (isDocumentNavigation(c)) return next();
|
|
12353
|
+
return c.json({ error: "Not found" }, 404);
|
|
12354
|
+
},
|
|
11114
12355
|
serveStatic({
|
|
11115
12356
|
root: UI_DIR_REL,
|
|
11116
12357
|
path: "index.html",
|
|
11117
12358
|
onFound: (_p, c) => c.header("Cache-Control", "no-cache")
|
|
11118
12359
|
})
|
|
11119
12360
|
);
|
|
12361
|
+
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
|
11120
12362
|
app.onError((err, c) => {
|
|
11121
12363
|
if (err instanceof AppError) {
|
|
11122
12364
|
return c.json({ error: err.message }, err.statusCode);
|
|
@@ -11289,6 +12531,111 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
11289
12531
|
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
11290
12532
|
);
|
|
11291
12533
|
}
|
|
12534
|
+
var NOTIFICATION_TITLES_MIGRATION_ID = "006_backfill_notification_titles";
|
|
12535
|
+
async function backfillNotificationTitles(db) {
|
|
12536
|
+
const already = await db.get(
|
|
12537
|
+
"SELECT id FROM schema_migrations WHERE id = ?",
|
|
12538
|
+
[NOTIFICATION_TITLES_MIGRATION_ID]
|
|
12539
|
+
);
|
|
12540
|
+
if (already) return;
|
|
12541
|
+
const rows = await db.all(
|
|
12542
|
+
"SELECT id, nest_id, message FROM notifications WHERE message LIKE '%nodes/%'"
|
|
12543
|
+
);
|
|
12544
|
+
let rewritten = 0;
|
|
12545
|
+
if (rows.length > 0) {
|
|
12546
|
+
const titlesByNest = /* @__PURE__ */ new Map();
|
|
12547
|
+
for (const nestId of new Set(rows.map((r) => r.nest_id))) {
|
|
12548
|
+
titlesByNest.set(nestId, await buildTitleMap(nestId));
|
|
12549
|
+
}
|
|
12550
|
+
for (const row of rows) {
|
|
12551
|
+
const titles = titlesByNest.get(row.nest_id) ?? /* @__PURE__ */ new Map();
|
|
12552
|
+
const next = row.message.replace(
|
|
12553
|
+
/"([^"]*nodes\/[^"]*)"/g,
|
|
12554
|
+
(whole, id) => {
|
|
12555
|
+
const label = describeNodeWith(titles, id);
|
|
12556
|
+
return `"${label.replace(/"/g, "")}"`;
|
|
12557
|
+
}
|
|
12558
|
+
);
|
|
12559
|
+
if (next === row.message) continue;
|
|
12560
|
+
await db.run("UPDATE notifications SET message = ? WHERE id = ?", [
|
|
12561
|
+
next,
|
|
12562
|
+
row.id
|
|
12563
|
+
]);
|
|
12564
|
+
rewritten++;
|
|
12565
|
+
}
|
|
12566
|
+
}
|
|
12567
|
+
await db.run(
|
|
12568
|
+
insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
|
|
12569
|
+
[NOTIFICATION_TITLES_MIGRATION_ID]
|
|
12570
|
+
);
|
|
12571
|
+
console.log(
|
|
12572
|
+
`[backfill] notifications: rewrote ${rewritten} of ${rows.length} message(s) carrying a node id`
|
|
12573
|
+
);
|
|
12574
|
+
}
|
|
12575
|
+
function describeNodeWith(titles, nodeId) {
|
|
12576
|
+
const at = nodeId.indexOf("nodes/");
|
|
12577
|
+
const normalized = at > 0 ? nodeId.slice(at) : nodeId;
|
|
12578
|
+
const title = titles.get(nodeId) || titles.get(normalized) || normalized.split("/").pop() || nodeId;
|
|
12579
|
+
const folder = folderForNode(normalized);
|
|
12580
|
+
return folder ? `${title} (in ${folder})` : title;
|
|
12581
|
+
}
|
|
12582
|
+
|
|
12583
|
+
// src/db/repair-version-chains.ts
|
|
12584
|
+
var MIGRATION_ID2 = "010_repair_grafted_version_chains";
|
|
12585
|
+
async function repairGraftedVersionChains(db) {
|
|
12586
|
+
const already = await db.get("SELECT id FROM schema_migrations WHERE id = ?", [
|
|
12587
|
+
MIGRATION_ID2
|
|
12588
|
+
]);
|
|
12589
|
+
if (already) return;
|
|
12590
|
+
const nests = await db.all("SELECT id FROM nests");
|
|
12591
|
+
let scanned = 0;
|
|
12592
|
+
let repaired = 0;
|
|
12593
|
+
let externalized = 0;
|
|
12594
|
+
for (const { id: nestId } of nests) {
|
|
12595
|
+
let storage;
|
|
12596
|
+
let versions;
|
|
12597
|
+
try {
|
|
12598
|
+
({ storage, versions } = await engineCache.get(nestId));
|
|
12599
|
+
} catch (err) {
|
|
12600
|
+
console.warn(
|
|
12601
|
+
`[repair] engine unavailable for nest ${nestId}:`,
|
|
12602
|
+
err.message
|
|
12603
|
+
);
|
|
12604
|
+
continue;
|
|
12605
|
+
}
|
|
12606
|
+
let docs;
|
|
12607
|
+
try {
|
|
12608
|
+
docs = await storage.discoverDocuments();
|
|
12609
|
+
} catch (err) {
|
|
12610
|
+
console.warn(
|
|
12611
|
+
`[repair] discoverDocuments failed for nest ${nestId}:`,
|
|
12612
|
+
err.message
|
|
12613
|
+
);
|
|
12614
|
+
continue;
|
|
12615
|
+
}
|
|
12616
|
+
for (const doc of docs) {
|
|
12617
|
+
scanned += 1;
|
|
12618
|
+
try {
|
|
12619
|
+
if (await versions.repairLatestVersion(doc.id)) {
|
|
12620
|
+
repaired += 1;
|
|
12621
|
+
console.log(`[repair] re-anchored version chain: ${nestId}/${doc.id}`);
|
|
12622
|
+
}
|
|
12623
|
+
externalized += await versions.externalizeDiffs(doc.id);
|
|
12624
|
+
} catch (err) {
|
|
12625
|
+
console.warn(
|
|
12626
|
+
`[repair] could not repair ${nestId}/${doc.id}:`,
|
|
12627
|
+
err.message
|
|
12628
|
+
);
|
|
12629
|
+
}
|
|
12630
|
+
}
|
|
12631
|
+
}
|
|
12632
|
+
await db.run(insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"), [
|
|
12633
|
+
MIGRATION_ID2
|
|
12634
|
+
]);
|
|
12635
|
+
console.log(
|
|
12636
|
+
`[repair] version chains: scanned ${scanned} docs across ${nests.length} nests, re-anchored ${repaired}, externalized ${externalized} inline change log(s)`
|
|
12637
|
+
);
|
|
12638
|
+
}
|
|
11292
12639
|
|
|
11293
12640
|
// src/workflow/scheduler.ts
|
|
11294
12641
|
import { v4 as uuid15 } from "uuid";
|
|
@@ -11366,6 +12713,16 @@ async function main() {
|
|
|
11366
12713
|
} catch (err) {
|
|
11367
12714
|
console.error("[backfill] node_versions backfill failed:", err);
|
|
11368
12715
|
}
|
|
12716
|
+
try {
|
|
12717
|
+
await repairGraftedVersionChains(db);
|
|
12718
|
+
} catch (err) {
|
|
12719
|
+
console.error("[repair] version chain repair failed:", err);
|
|
12720
|
+
}
|
|
12721
|
+
try {
|
|
12722
|
+
await backfillNotificationTitles(db);
|
|
12723
|
+
} catch (err) {
|
|
12724
|
+
console.error("[backfill] notification title backfill failed:", err);
|
|
12725
|
+
}
|
|
11369
12726
|
const accessCfg = loadAccessConfig();
|
|
11370
12727
|
if (accessCfg) {
|
|
11371
12728
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|
|
@@ -11437,6 +12794,7 @@ async function main() {
|
|
|
11437
12794
|
POST /nests/:id/query Selector query
|
|
11438
12795
|
POST /nests/:id/context One-call context retrieval
|
|
11439
12796
|
GET /nests/:id/search?q= Full-text search
|
|
12797
|
+
GET /search?q= Search every nest you can see
|
|
11440
12798
|
POST /nests/:id/publish Bulk publish
|
|
11441
12799
|
POST /nests/:id/mcp MCP endpoint
|
|
11442
12800
|
GET /health Health check
|