@promptowl/contextnest-community 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +71 -1
- package/README.md +2 -0
- package/dist/{chunk-ZQS5W45U.js → chunk-5IAKUUZF.js} +1 -1
- package/dist/{chunk-Q5NRCDD4.js → chunk-7BOG3S5H.js} +1560 -1574
- package/dist/{chunk-5QQ7WKAI.js → chunk-BCFKLY4H.js} +76 -1
- package/dist/{chunk-PAIP5KHB.js → chunk-C4U75IUR.js} +790 -515
- package/dist/chunk-GWDOLYFJ.js +624 -0
- package/dist/{chunk-EG77SQHO.js → chunk-JWVZ3T35.js} +58 -3
- package/dist/{client-AAMF22YJ.js → client-NFUHPLLM.js} +1 -1
- package/dist/{grants-service-FZD6QM6V.js → grants-service-O4AO326A.js} +2 -2
- package/dist/index.js +1833 -814
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-JUAFYB73.js +47 -0
- package/dist/{stewardship-service-B6L7UACX.js → stewardship-service-FHJ2RBKN.js} +4 -4
- package/dist/{version-service-L52F4WVZ.js → version-service-ECYCBVSE.js} +7 -3
- package/dist/web3/assets/index-BaMJ4tEz.css +1 -0
- package/dist/web3/assets/index-D950G4hR.js +1052 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -2
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-Q3NDK5TX.js +0 -29
- package/dist/web3/assets/index-DcmDj50q.css +0 -1
- package/dist/web3/assets/index-EGPZ6MAy.js +0 -1023
package/dist/index.js
CHANGED
|
@@ -4,31 +4,45 @@ 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,
|
|
26
|
+
nodeWriteKey,
|
|
27
|
+
notifyDeletionResolved,
|
|
20
28
|
notifyNestEvent,
|
|
29
|
+
notifyNestInvite,
|
|
21
30
|
parseConditionSchema,
|
|
22
31
|
reject,
|
|
23
32
|
removeWatcher,
|
|
33
|
+
repointPendingReview,
|
|
34
|
+
requestDeletion,
|
|
24
35
|
requireWorkflowPlane,
|
|
25
36
|
resolveCallerEmail,
|
|
26
37
|
safeJson,
|
|
27
38
|
safePublishDocument,
|
|
28
39
|
seedDefaultEdgeTypes,
|
|
40
|
+
sendTestNotification,
|
|
41
|
+
serializeNodeSafe,
|
|
42
|
+
stripUndefinedDeep,
|
|
29
43
|
submitForReview,
|
|
30
|
-
|
|
31
|
-
} from "./chunk-
|
|
44
|
+
withNodeWriteLock
|
|
45
|
+
} from "./chunk-C4U75IUR.js";
|
|
32
46
|
import {
|
|
33
47
|
createGrant,
|
|
34
48
|
deleteGrant,
|
|
@@ -37,7 +51,7 @@ import {
|
|
|
37
51
|
listGrants,
|
|
38
52
|
listUserGrants,
|
|
39
53
|
resolveNodeGrant
|
|
40
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-5IAKUUZF.js";
|
|
41
55
|
import {
|
|
42
56
|
generateApiKey,
|
|
43
57
|
getKeyPrefix,
|
|
@@ -53,19 +67,35 @@ import {
|
|
|
53
67
|
getCurrentVersion,
|
|
54
68
|
getDisplayStatus,
|
|
55
69
|
getVersions,
|
|
70
|
+
listMyDrafts,
|
|
56
71
|
setApprovedVersion,
|
|
57
72
|
upsertVersion
|
|
58
|
-
} from "./chunk-
|
|
73
|
+
} from "./chunk-JWVZ3T35.js";
|
|
59
74
|
import {
|
|
60
|
-
addMember,
|
|
61
|
-
buildTitleMap,
|
|
62
75
|
canCreateInNest,
|
|
63
76
|
canManageStewards,
|
|
64
77
|
canUserAccess,
|
|
65
78
|
canUserApprove,
|
|
66
79
|
canUserEdit,
|
|
67
|
-
createNest,
|
|
68
80
|
createStewardRecord,
|
|
81
|
+
getCollaboratorRole,
|
|
82
|
+
getStewardRolesForUser,
|
|
83
|
+
getStewardsForNest,
|
|
84
|
+
getStewardsForUser,
|
|
85
|
+
listStewards,
|
|
86
|
+
removeSteward,
|
|
87
|
+
resolveGrantRoles,
|
|
88
|
+
resolveStewardsForNode,
|
|
89
|
+
resolveStewardsWithFallback,
|
|
90
|
+
resolveUserRoles,
|
|
91
|
+
syncFromConfig,
|
|
92
|
+
updateSteward
|
|
93
|
+
} from "./chunk-GWDOLYFJ.js";
|
|
94
|
+
import {
|
|
95
|
+
addMember,
|
|
96
|
+
buildTitleMap,
|
|
97
|
+
canApproveWith,
|
|
98
|
+
createNest,
|
|
69
99
|
createTeam,
|
|
70
100
|
deleteNest,
|
|
71
101
|
deleteTeam,
|
|
@@ -73,16 +103,14 @@ import {
|
|
|
73
103
|
docLink,
|
|
74
104
|
engineCache,
|
|
75
105
|
findTeamByExternalId,
|
|
106
|
+
folderForNode,
|
|
76
107
|
getAccessConfig,
|
|
77
|
-
getCollaboratorRole,
|
|
78
108
|
getCurrentLicense,
|
|
79
109
|
getNest,
|
|
80
|
-
getStewardRolesForUser,
|
|
81
|
-
getStewardsForNest,
|
|
82
|
-
getStewardsForUser,
|
|
83
110
|
getSuspensionReason,
|
|
84
111
|
getTeam,
|
|
85
112
|
importNest,
|
|
113
|
+
insertOrIgnore,
|
|
86
114
|
installLicenseKey,
|
|
87
115
|
isConfigSuperAdmin,
|
|
88
116
|
isLicenseAdminEmail,
|
|
@@ -96,7 +124,6 @@ import {
|
|
|
96
124
|
listNests,
|
|
97
125
|
listPublicNests,
|
|
98
126
|
listSharedNests,
|
|
99
|
-
listStewards,
|
|
100
127
|
listTeamsForUser,
|
|
101
128
|
listVisibleNests,
|
|
102
129
|
loadAccessConfig,
|
|
@@ -104,34 +131,37 @@ import {
|
|
|
104
131
|
loadServerSettings,
|
|
105
132
|
nestAllowsSelfApprove,
|
|
106
133
|
nestName,
|
|
134
|
+
nestPrimeTags,
|
|
135
|
+
nestReviewsPrimeOnly,
|
|
107
136
|
nestStorageRoot,
|
|
137
|
+
normalizeTag,
|
|
138
|
+
nowExpr,
|
|
108
139
|
permissionLevel,
|
|
109
140
|
persistSetting,
|
|
110
141
|
removeMember,
|
|
111
|
-
|
|
142
|
+
removeNodeFromTagIndex,
|
|
112
143
|
renameNest,
|
|
113
144
|
renameTeam,
|
|
114
145
|
replaceTeamRoster,
|
|
115
|
-
resolveGrantRoles,
|
|
116
146
|
resolveMemberIdentity,
|
|
117
147
|
resolveNestPath,
|
|
118
148
|
resolveNestPermission,
|
|
119
|
-
|
|
120
|
-
resolveStewardsWithFallback,
|
|
121
|
-
resolveUserRoles,
|
|
149
|
+
sendEmailToRecipient,
|
|
122
150
|
setAllowSelfApprove,
|
|
151
|
+
setPrimeOnlyReview,
|
|
152
|
+
setPrimeTags,
|
|
123
153
|
setStewardshipEnabled,
|
|
124
154
|
shareTeamWithNest,
|
|
125
155
|
startLicenseSafetyPoll,
|
|
126
156
|
startTelemetryLoop,
|
|
127
|
-
|
|
157
|
+
syncNodeTags,
|
|
128
158
|
trackEvent,
|
|
129
159
|
uniqueNestName,
|
|
130
160
|
unshareTeamFromNest,
|
|
131
161
|
updateMemberRole,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} from "./chunk-
|
|
162
|
+
validateLicense,
|
|
163
|
+
verifySmtp
|
|
164
|
+
} from "./chunk-7BOG3S5H.js";
|
|
135
165
|
import {
|
|
136
166
|
AppError,
|
|
137
167
|
ConflictError,
|
|
@@ -141,24 +171,20 @@ import {
|
|
|
141
171
|
UnauthorizedError,
|
|
142
172
|
ValidationError
|
|
143
173
|
} from "./chunk-GUNJTORH.js";
|
|
144
|
-
import {
|
|
145
|
-
normalizeEmail
|
|
146
|
-
} from "./chunk-FRQJWGN3.js";
|
|
147
|
-
import {
|
|
148
|
-
insertOrIgnore,
|
|
149
|
-
nowExpr
|
|
150
|
-
} from "./chunk-XQ46F76G.js";
|
|
151
174
|
import {
|
|
152
175
|
config,
|
|
153
176
|
getDb,
|
|
154
177
|
initDb,
|
|
155
178
|
isEmailListish,
|
|
156
179
|
isEmailish
|
|
157
|
-
} from "./chunk-
|
|
180
|
+
} from "./chunk-BCFKLY4H.js";
|
|
158
181
|
import {
|
|
159
182
|
ANON_EMAIL,
|
|
160
183
|
ANON_USER_ID
|
|
161
184
|
} from "./chunk-SLTQACJW.js";
|
|
185
|
+
import {
|
|
186
|
+
normalizeEmail
|
|
187
|
+
} from "./chunk-FRQJWGN3.js";
|
|
162
188
|
|
|
163
189
|
// src/index.ts
|
|
164
190
|
import { serve } from "@hono/node-server";
|
|
@@ -386,6 +412,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
|
|
|
386
412
|
|
|
387
413
|
// src/auth/sso-token.ts
|
|
388
414
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
415
|
+
function isValidTicketSub(sub) {
|
|
416
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
|
|
417
|
+
}
|
|
389
418
|
function b64urlToBuffer(input) {
|
|
390
419
|
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
391
420
|
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
@@ -1208,6 +1237,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1208
1237
|
} catch {
|
|
1209
1238
|
return ssoError("invalid_ticket");
|
|
1210
1239
|
}
|
|
1240
|
+
if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
|
|
1211
1241
|
const expectedAud = config.PUBLIC_BASE_URL;
|
|
1212
1242
|
if (!expectedAud && !warnedMissingSsoAudience) {
|
|
1213
1243
|
warnedMissingSsoAudience = true;
|
|
@@ -1220,7 +1250,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1220
1250
|
if (aud !== expectedAud) return ssoError("bad_audience");
|
|
1221
1251
|
}
|
|
1222
1252
|
const sub = (claims.sub || "").trim();
|
|
1223
|
-
if (
|
|
1253
|
+
if (!isValidTicketSub(sub)) {
|
|
1224
1254
|
return ssoError("invalid_ticket");
|
|
1225
1255
|
}
|
|
1226
1256
|
if (!claims.jti) return ssoError("invalid_ticket");
|
|
@@ -1476,6 +1506,18 @@ authRoutes.post("/invite", async (c) => {
|
|
|
1476
1506
|
);
|
|
1477
1507
|
}
|
|
1478
1508
|
trackEvent("admin.invite", { adminId: callerId, email });
|
|
1509
|
+
const inviter = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
1510
|
+
callerId
|
|
1511
|
+
]);
|
|
1512
|
+
void sendEmailToRecipient(email, {
|
|
1513
|
+
status: "invited",
|
|
1514
|
+
docTitle: "ContextNest",
|
|
1515
|
+
path: "",
|
|
1516
|
+
actor: email,
|
|
1517
|
+
by: inviter?.email || void 0,
|
|
1518
|
+
tempPassword,
|
|
1519
|
+
link: requestBaseUrl(c.req.url)
|
|
1520
|
+
});
|
|
1479
1521
|
return c.json(
|
|
1480
1522
|
{
|
|
1481
1523
|
temporary_password: tempPassword,
|
|
@@ -1544,42 +1586,27 @@ import { streamText } from "hono/streaming";
|
|
|
1544
1586
|
import {
|
|
1545
1587
|
serializeDocument,
|
|
1546
1588
|
parseDocument as parseDocument2,
|
|
1547
|
-
normalizeStatus
|
|
1589
|
+
normalizeStatus,
|
|
1590
|
+
DocumentNotFoundError
|
|
1548
1591
|
} from "@promptowl/contextnest-engine";
|
|
1549
1592
|
import { rename, mkdir, rm } from "fs/promises";
|
|
1550
1593
|
import { dirname, join as join2, basename } from "path";
|
|
1551
1594
|
|
|
1552
|
-
// src/governance/
|
|
1553
|
-
function
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
await db.transaction(async (tx) => {
|
|
1568
|
-
await tx.run(
|
|
1569
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1570
|
-
[nestId, nodeId]
|
|
1571
|
-
);
|
|
1572
|
-
for (const tag of normalized) {
|
|
1573
|
-
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1574
|
-
}
|
|
1575
|
-
});
|
|
1576
|
-
}
|
|
1577
|
-
async function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1578
|
-
const db = getDb();
|
|
1579
|
-
await db.run(
|
|
1580
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1581
|
-
[nestId, nodeId]
|
|
1582
|
-
);
|
|
1595
|
+
// src/governance/prime-service.ts
|
|
1596
|
+
async function isPrimeDocument(nestId, tags, metadata) {
|
|
1597
|
+
const flag = documentPrimeFlag(metadata);
|
|
1598
|
+
if (flag !== void 0) return flag;
|
|
1599
|
+
const primeTags = await nestPrimeTags(nestId);
|
|
1600
|
+
if (!primeTags.length) return false;
|
|
1601
|
+
const wanted = new Set(primeTags);
|
|
1602
|
+
return (tags || []).some((t) => wanted.has(normalizeTag(String(t))));
|
|
1603
|
+
}
|
|
1604
|
+
function documentPrimeFlag(metadata) {
|
|
1605
|
+
const raw = metadata?.prime;
|
|
1606
|
+
if (typeof raw === "boolean") return raw;
|
|
1607
|
+
if (raw === "true") return true;
|
|
1608
|
+
if (raw === "false") return false;
|
|
1609
|
+
return void 0;
|
|
1583
1610
|
}
|
|
1584
1611
|
|
|
1585
1612
|
// src/governance/external-edit-service.ts
|
|
@@ -1765,7 +1792,7 @@ async function approveExternalEdit(input) {
|
|
|
1765
1792
|
const node = await storage.readDocument(input.documentId);
|
|
1766
1793
|
const versionNum = result.versionEntry.version;
|
|
1767
1794
|
const tags = node.frontmatter.tags || [];
|
|
1768
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1795
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
|
|
1769
1796
|
await createVersion2({
|
|
1770
1797
|
nestId: input.nestId,
|
|
1771
1798
|
nodeId: input.documentId,
|
|
@@ -1846,9 +1873,21 @@ async function userIdFromEmail(email) {
|
|
|
1846
1873
|
return row?.id ?? ANON_USER_ID;
|
|
1847
1874
|
}
|
|
1848
1875
|
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1876
|
+
var MAX_TITLE_LENGTH = 120;
|
|
1877
|
+
function assertTitleLength(title) {
|
|
1878
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
1879
|
+
throw new ValidationError(
|
|
1880
|
+
`title must be at most ${MAX_TITLE_LENGTH} characters`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1849
1884
|
var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1885
|
+
function isIdUnderFolder(id, folder) {
|
|
1886
|
+
return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
|
|
1887
|
+
}
|
|
1850
1888
|
function folderToSegments(folder) {
|
|
1851
1889
|
const segments = folder.split("/").map(slugify).filter(Boolean);
|
|
1890
|
+
if (segments[0] === "nodes") segments.shift();
|
|
1852
1891
|
if (segments.length > 8) {
|
|
1853
1892
|
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1854
1893
|
}
|
|
@@ -1874,18 +1913,6 @@ async function departmentTagFor(userEmail) {
|
|
|
1874
1913
|
return null;
|
|
1875
1914
|
}
|
|
1876
1915
|
}
|
|
1877
|
-
var nodeWriteChains = /* @__PURE__ */ new Map();
|
|
1878
|
-
function withNodeWriteLock(key, fn) {
|
|
1879
|
-
const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
|
|
1880
|
-
});
|
|
1881
|
-
const run = prev.then(fn);
|
|
1882
|
-
nodeWriteChains.set(key, run);
|
|
1883
|
-
void run.catch(() => {
|
|
1884
|
-
}).finally(() => {
|
|
1885
|
-
if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
|
|
1886
|
-
});
|
|
1887
|
-
return run;
|
|
1888
|
-
}
|
|
1889
1916
|
function toSafeError(err, context, message) {
|
|
1890
1917
|
if (err instanceof AppError) return err;
|
|
1891
1918
|
console.error(`${context}:`, err);
|
|
@@ -1921,7 +1948,8 @@ function toNodeResponse(node) {
|
|
|
1921
1948
|
pendingChange: node.pendingChange ?? void 0,
|
|
1922
1949
|
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1923
1950
|
// number — normalize to string at the API boundary.
|
|
1924
|
-
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
|
|
1951
|
+
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
|
|
1952
|
+
prime: documentPrimeFlag(fm.metadata)
|
|
1925
1953
|
};
|
|
1926
1954
|
}
|
|
1927
1955
|
async function resolveAuthorship(nestId, nodes) {
|
|
@@ -2062,17 +2090,29 @@ async function actorAutoPublishes(nestId, userEmail) {
|
|
|
2062
2090
|
const roles = await resolveUserRoles(nestId, userEmail);
|
|
2063
2091
|
return roles.includes("owner") || roles.includes("admin");
|
|
2064
2092
|
}
|
|
2093
|
+
async function assertMayFlagPrime(nestId, userEmail) {
|
|
2094
|
+
const roles = await resolveUserRoles(nestId, userEmail);
|
|
2095
|
+
if (!roles.includes("owner") && !roles.includes("admin")) {
|
|
2096
|
+
throw new ForbiddenError(
|
|
2097
|
+
"Only the nest owner or an admin can change a document's prime flag."
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function autoPublishReason(nestId, userEmail, doc) {
|
|
2102
|
+
if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
|
|
2103
|
+
if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
|
|
2104
|
+
if (await nestReviewsPrimeOnly(nestId)) {
|
|
2105
|
+
return "prime-only review, document is not prime";
|
|
2106
|
+
}
|
|
2107
|
+
return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
|
|
2108
|
+
}
|
|
2065
2109
|
async function createNode(nestId, input, userEmail) {
|
|
2066
2110
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2067
|
-
|
|
2111
|
+
assertTitleLength(input.title);
|
|
2112
|
+
const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
|
|
2068
2113
|
let id = input.id;
|
|
2069
2114
|
if (!id) {
|
|
2070
2115
|
const folderSegments = folderToSegments(input.folder ?? "");
|
|
2071
|
-
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
2072
|
-
throw new ValidationError(
|
|
2073
|
-
"each folder or title segment must be at most 100 characters"
|
|
2074
|
-
);
|
|
2075
|
-
}
|
|
2076
2116
|
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
2077
2117
|
}
|
|
2078
2118
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2091,72 +2131,75 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2091
2131
|
)})`
|
|
2092
2132
|
);
|
|
2093
2133
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
tags.push(deptTag);
|
|
2097
|
-
}
|
|
2098
|
-
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2099
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2100
|
-
const initialStatus = autoPublish ? "published" : "draft";
|
|
2101
|
-
const initialVersion = autoPublish ? 0 : 1;
|
|
2102
|
-
let node = {
|
|
2103
|
-
id,
|
|
2104
|
-
filePath: "",
|
|
2105
|
-
frontmatter: {
|
|
2106
|
-
title: input.title,
|
|
2107
|
-
type: requestedType,
|
|
2108
|
-
tags,
|
|
2109
|
-
status: input.status || initialStatus,
|
|
2110
|
-
version: initialVersion,
|
|
2111
|
-
created_at: now,
|
|
2112
|
-
updated_at: now,
|
|
2113
|
-
metadata: {
|
|
2114
|
-
owners: ["*"],
|
|
2115
|
-
scope: input.scope || "team",
|
|
2116
|
-
...input.schedule ? { schedule: input.schedule } : {}
|
|
2117
|
-
}
|
|
2118
|
-
},
|
|
2119
|
-
body: input.content,
|
|
2120
|
-
rawContent: ""
|
|
2121
|
-
};
|
|
2122
|
-
await storage.writeDocument(id, serializeDocument(node));
|
|
2123
|
-
await syncNodeTags(nestId, id, tags);
|
|
2124
|
-
let savedVersion = 1;
|
|
2125
|
-
if (!autoPublish) {
|
|
2134
|
+
return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
|
|
2135
|
+
let occupied = false;
|
|
2126
2136
|
try {
|
|
2127
|
-
await
|
|
2137
|
+
await storage.readDocument(id);
|
|
2138
|
+
occupied = true;
|
|
2128
2139
|
} catch (err) {
|
|
2129
|
-
|
|
2140
|
+
if (!(err instanceof DocumentNotFoundError)) throw err;
|
|
2130
2141
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
await createVersion({
|
|
2148
|
-
nestId,
|
|
2149
|
-
nodeId: id,
|
|
2150
|
-
version: savedVersion,
|
|
2151
|
-
content: result.node.body || "",
|
|
2152
|
-
author: userEmail,
|
|
2153
|
-
status: "published",
|
|
2154
|
-
tags
|
|
2142
|
+
if (occupied) {
|
|
2143
|
+
throw new ConflictError(
|
|
2144
|
+
`A document already exists at ${id}. Update it, or create this one under a different folder or title.`
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
const db = getDb();
|
|
2148
|
+
const orphan = await db.get(
|
|
2149
|
+
"SELECT 1 AS present FROM node_versions WHERE nest_id = ? AND node_id = ? LIMIT 1",
|
|
2150
|
+
[nestId, id]
|
|
2151
|
+
);
|
|
2152
|
+
if (orphan) {
|
|
2153
|
+
console.warn(
|
|
2154
|
+
`[nodes] clearing orphaned rows for ${id} \u2014 version history exists but no document does`
|
|
2155
|
+
);
|
|
2156
|
+
await db.transaction(async (tx) => {
|
|
2157
|
+
await purgeNodeKeyedRows(tx, nestId, id);
|
|
2155
2158
|
});
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2159
|
+
}
|
|
2160
|
+
const deptTag = await departmentTagFor(userEmail);
|
|
2161
|
+
if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
|
|
2162
|
+
tags.push(deptTag);
|
|
2163
|
+
}
|
|
2164
|
+
if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
|
|
2165
|
+
const metadata = {
|
|
2166
|
+
owners: ["*"],
|
|
2167
|
+
scope: input.scope || "team",
|
|
2168
|
+
...input.schedule ? { schedule: input.schedule } : {},
|
|
2169
|
+
...input.prime === void 0 ? {} : { prime: input.prime }
|
|
2170
|
+
};
|
|
2171
|
+
const publishReason = await autoPublishReason(nestId, userEmail, {
|
|
2172
|
+
tags,
|
|
2173
|
+
metadata
|
|
2174
|
+
});
|
|
2175
|
+
const autoPublish = publishReason !== null;
|
|
2176
|
+
const initialStatus = autoPublish ? "published" : "draft";
|
|
2177
|
+
const initialVersion = autoPublish ? 0 : 1;
|
|
2178
|
+
let node = {
|
|
2179
|
+
id,
|
|
2180
|
+
filePath: "",
|
|
2181
|
+
frontmatter: {
|
|
2182
|
+
title: input.title,
|
|
2183
|
+
type: requestedType,
|
|
2184
|
+
tags,
|
|
2185
|
+
status: input.status || initialStatus,
|
|
2186
|
+
version: initialVersion,
|
|
2187
|
+
created_at: now,
|
|
2188
|
+
updated_at: now,
|
|
2189
|
+
metadata
|
|
2190
|
+
},
|
|
2191
|
+
body: input.content,
|
|
2192
|
+
rawContent: ""
|
|
2193
|
+
};
|
|
2194
|
+
await storage.writeDocument(id, serializeDocument(node));
|
|
2195
|
+
await syncNodeTags(nestId, id, tags);
|
|
2196
|
+
let savedVersion = 1;
|
|
2197
|
+
if (publishReason === null) {
|
|
2198
|
+
try {
|
|
2199
|
+
await versionManager.createVersion(node, userEmail);
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
2202
|
+
}
|
|
2160
2203
|
await createVersion({
|
|
2161
2204
|
nestId,
|
|
2162
2205
|
nodeId: id,
|
|
@@ -2166,10 +2209,42 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2166
2209
|
status: "draft",
|
|
2167
2210
|
tags
|
|
2168
2211
|
});
|
|
2212
|
+
} else {
|
|
2213
|
+
const publishNote = `Auto-published on create (${publishReason})`;
|
|
2214
|
+
try {
|
|
2215
|
+
const result = await safePublishDocument(storage, id, {
|
|
2216
|
+
editedBy: userEmail,
|
|
2217
|
+
note: publishNote
|
|
2218
|
+
});
|
|
2219
|
+
savedVersion = result.node.frontmatter.version || 1;
|
|
2220
|
+
await createVersion({
|
|
2221
|
+
nestId,
|
|
2222
|
+
nodeId: id,
|
|
2223
|
+
version: savedVersion,
|
|
2224
|
+
content: result.node.body || "",
|
|
2225
|
+
author: userEmail,
|
|
2226
|
+
status: "published",
|
|
2227
|
+
tags,
|
|
2228
|
+
changeNote: publishNote
|
|
2229
|
+
});
|
|
2230
|
+
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
2231
|
+
node = result.node;
|
|
2232
|
+
} catch (err) {
|
|
2233
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
2234
|
+
await createVersion({
|
|
2235
|
+
nestId,
|
|
2236
|
+
nodeId: id,
|
|
2237
|
+
version: 1,
|
|
2238
|
+
content: input.content,
|
|
2239
|
+
author: userEmail,
|
|
2240
|
+
status: "draft",
|
|
2241
|
+
tags
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2169
2244
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2245
|
+
await trackEvent("node.create", { nestId, nodeId: id });
|
|
2246
|
+
return { node, version: savedVersion };
|
|
2247
|
+
});
|
|
2173
2248
|
}
|
|
2174
2249
|
function explicitFrontmatterStatus(raw) {
|
|
2175
2250
|
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
@@ -2189,7 +2264,7 @@ async function resetImportedDocForPublish(storage, nestId, nodeId) {
|
|
|
2189
2264
|
const fm = { ...node.frontmatter };
|
|
2190
2265
|
delete fm.version;
|
|
2191
2266
|
delete fm.checksum;
|
|
2192
|
-
await storage.writeDocument(nodeId,
|
|
2267
|
+
await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
|
|
2193
2268
|
}
|
|
2194
2269
|
async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
2195
2270
|
const { storage } = await engineCache.get(nestId);
|
|
@@ -2219,7 +2294,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
|
2219
2294
|
const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
|
|
2220
2295
|
doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
|
|
2221
2296
|
try {
|
|
2222
|
-
await storage.writeDocument(nodeId,
|
|
2297
|
+
await storage.writeDocument(nodeId, serializeNodeSafe(doc));
|
|
2223
2298
|
} catch (err) {
|
|
2224
2299
|
console.error("import: failed to persist import metadata", nodeId, err);
|
|
2225
2300
|
}
|
|
@@ -2300,7 +2375,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
|
2300
2375
|
return registered;
|
|
2301
2376
|
}
|
|
2302
2377
|
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
2303
|
-
return withNodeWriteLock(
|
|
2378
|
+
return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
|
|
2304
2379
|
try {
|
|
2305
2380
|
const { storage, versions: versionManager } = await engineCache.get(
|
|
2306
2381
|
nestId
|
|
@@ -2331,6 +2406,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2331
2406
|
};
|
|
2332
2407
|
}
|
|
2333
2408
|
if (patch.title) {
|
|
2409
|
+
if (patch.title !== node.frontmatter.title) {
|
|
2410
|
+
assertTitleLength(patch.title);
|
|
2411
|
+
}
|
|
2334
2412
|
node = {
|
|
2335
2413
|
...node,
|
|
2336
2414
|
frontmatter: { ...node.frontmatter, title: patch.title }
|
|
@@ -2349,18 +2427,30 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2349
2427
|
else metadata.schedule = patch.schedule;
|
|
2350
2428
|
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2351
2429
|
}
|
|
2430
|
+
if (patch.prime !== void 0) {
|
|
2431
|
+
await assertMayFlagPrime(nestId, userEmail);
|
|
2432
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
2433
|
+
if (patch.prime === null) delete metadata.prime;
|
|
2434
|
+
else metadata.prime = patch.prime;
|
|
2435
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2436
|
+
}
|
|
2352
2437
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2353
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2354
2438
|
const currentTags = node.frontmatter.tags || [];
|
|
2355
|
-
|
|
2439
|
+
const publishReason = await autoPublishReason(nestId, userEmail, {
|
|
2440
|
+
tags: currentTags,
|
|
2441
|
+
metadata: node.frontmatter.metadata
|
|
2442
|
+
});
|
|
2443
|
+
const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
|
|
2444
|
+
const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
|
|
2445
|
+
if (pendingReview && !isAuthorEditingOwnReview) {
|
|
2356
2446
|
throw new LockedError(
|
|
2357
2447
|
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
2358
2448
|
);
|
|
2359
2449
|
}
|
|
2360
2450
|
let responseVersion;
|
|
2361
|
-
if (
|
|
2451
|
+
if (publishReason === null || isAuthorEditingOwnReview) {
|
|
2362
2452
|
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
2363
|
-
const newVersion =
|
|
2453
|
+
const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
|
|
2364
2454
|
node = {
|
|
2365
2455
|
...node,
|
|
2366
2456
|
frontmatter: {
|
|
@@ -2396,6 +2486,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2396
2486
|
tags: currentTags,
|
|
2397
2487
|
changeNote: patch.changeNote
|
|
2398
2488
|
});
|
|
2489
|
+
if (isAuthorEditingOwnReview) {
|
|
2490
|
+
await repointPendingReview({ nestId, nodeId, newVersion });
|
|
2491
|
+
}
|
|
2399
2492
|
responseVersion = newVersion;
|
|
2400
2493
|
} else {
|
|
2401
2494
|
node = {
|
|
@@ -2410,10 +2503,11 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2410
2503
|
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2411
2504
|
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2412
2505
|
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2506
|
+
const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
|
|
2413
2507
|
try {
|
|
2414
2508
|
const result = await safePublishDocument(storage, nodeId, {
|
|
2415
2509
|
editedBy: userEmail,
|
|
2416
|
-
note:
|
|
2510
|
+
note: publishNote
|
|
2417
2511
|
});
|
|
2418
2512
|
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
2419
2513
|
node = result.node;
|
|
@@ -2432,7 +2526,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2432
2526
|
author: userEmail,
|
|
2433
2527
|
status: "published",
|
|
2434
2528
|
tags: currentTags,
|
|
2435
|
-
changeNote:
|
|
2529
|
+
changeNote: publishNote
|
|
2436
2530
|
});
|
|
2437
2531
|
await setApprovedVersion(
|
|
2438
2532
|
nestId,
|
|
@@ -2452,9 +2546,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2452
2546
|
}
|
|
2453
2547
|
});
|
|
2454
2548
|
}
|
|
2549
|
+
async function assertNotReviewLocked(nestId, nodeId, userEmail, action) {
|
|
2550
|
+
if (!await isStewardshipEnabled(nestId)) return;
|
|
2551
|
+
if (!await getPendingReview(nestId, nodeId)) return;
|
|
2552
|
+
if (canApproveWith(await resolveUserRoles(nestId, userEmail, { nodeId }))) return;
|
|
2553
|
+
throw new LockedError(
|
|
2554
|
+
`This document is awaiting steward review and is locked. Approve or reject the pending review before ${action}, or ask a reviewer to.`
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2455
2557
|
function derivedIdOf(sourceNodeId) {
|
|
2456
2558
|
return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
|
|
2457
2559
|
}
|
|
2560
|
+
var NODE_KEYED_TABLES = [
|
|
2561
|
+
["node_versions", "node_id"],
|
|
2562
|
+
["review_requests", "node_id"],
|
|
2563
|
+
["deletion_requests", "node_id"],
|
|
2564
|
+
["approved_versions", "node_id"],
|
|
2565
|
+
["annotation_threads", "node_id"],
|
|
2566
|
+
["comments", "node_id"],
|
|
2567
|
+
["node_tag_index", "node_id"],
|
|
2568
|
+
["watchers", "node_id"],
|
|
2569
|
+
["schedules", "agent_node"],
|
|
2570
|
+
// A live capability token bound to one agent node — deleting the agent must
|
|
2571
|
+
// revoke it, or a later node at that id answers the old webhook.
|
|
2572
|
+
["trigger_hooks", "agent_node"],
|
|
2573
|
+
["edges", "from_node"],
|
|
2574
|
+
["edges", "to_node"]
|
|
2575
|
+
];
|
|
2576
|
+
async function purgeNodeKeyedRows(tx, nestId, nodeId) {
|
|
2577
|
+
for (const [table, column] of NODE_KEYED_TABLES) {
|
|
2578
|
+
await tx.run(`DELETE FROM ${table} WHERE nest_id = ? AND ${column} = ?`, [
|
|
2579
|
+
nestId,
|
|
2580
|
+
nodeId
|
|
2581
|
+
]);
|
|
2582
|
+
}
|
|
2583
|
+
await tx.run(
|
|
2584
|
+
`DELETE FROM stewards
|
|
2585
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2586
|
+
[nestId, nodeId]
|
|
2587
|
+
);
|
|
2588
|
+
await tx.run(
|
|
2589
|
+
`DELETE FROM grants
|
|
2590
|
+
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2591
|
+
[nestId, nodeId]
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
async function rewriteNodeKeyedRows(tx, nestId, oldId, newId) {
|
|
2595
|
+
for (const [table, column] of NODE_KEYED_TABLES) {
|
|
2596
|
+
await tx.run(
|
|
2597
|
+
`UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
|
|
2598
|
+
[newId, nestId, oldId]
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
await tx.run(
|
|
2602
|
+
`UPDATE stewards SET node_pattern = ?
|
|
2603
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2604
|
+
[newId, nestId, oldId]
|
|
2605
|
+
);
|
|
2606
|
+
await tx.run(
|
|
2607
|
+
`UPDATE grants SET target = ?
|
|
2608
|
+
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2609
|
+
[newId, nestId, oldId]
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2458
2612
|
async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
2459
2613
|
const { storage } = await engineCache.get(nestId);
|
|
2460
2614
|
let node;
|
|
@@ -2463,103 +2617,124 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
|
2463
2617
|
} catch {
|
|
2464
2618
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2465
2619
|
}
|
|
2466
|
-
|
|
2467
|
-
throw new LockedError(
|
|
2468
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
|
|
2469
|
-
);
|
|
2470
|
-
}
|
|
2620
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
|
|
2471
2621
|
const leaf = nodeId.split("/").pop();
|
|
2472
2622
|
const segments = folderToSegments(folder);
|
|
2473
2623
|
const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
|
|
2474
2624
|
if (newId === nodeId) return { node, previousId: nodeId };
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
...node,
|
|
2485
|
-
id: newId,
|
|
2486
|
-
frontmatter: {
|
|
2487
|
-
...node.frontmatter,
|
|
2488
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2625
|
+
return withNodeWriteLock(nodeWriteKey(nestId, newId), async () => {
|
|
2626
|
+
try {
|
|
2627
|
+
await storage.readDocument(newId);
|
|
2628
|
+
throw new ConflictError(
|
|
2629
|
+
`A document already exists at that location: ${newId}`
|
|
2630
|
+
);
|
|
2631
|
+
} catch (err) {
|
|
2632
|
+
if (err instanceof ConflictError) throw err;
|
|
2633
|
+
if (!(err instanceof DocumentNotFoundError)) throw err;
|
|
2489
2634
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2635
|
+
const moved = {
|
|
2636
|
+
...node,
|
|
2637
|
+
id: newId,
|
|
2638
|
+
frontmatter: {
|
|
2639
|
+
...node.frontmatter,
|
|
2640
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
await storage.writeDocument(newId, serializeNodeSafe(moved));
|
|
2644
|
+
try {
|
|
2645
|
+
const nestPath = resolveNestPath(nestId);
|
|
2646
|
+
const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
|
|
2647
|
+
const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
|
|
2648
|
+
if (oldChain !== newChain) {
|
|
2649
|
+
await mkdir(dirname(newChain), { recursive: true });
|
|
2650
|
+
await rename(oldChain, newChain);
|
|
2651
|
+
}
|
|
2652
|
+
} catch {
|
|
2499
2653
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
for (const [table, column] of [
|
|
2508
|
-
["node_versions", "node_id"],
|
|
2509
|
-
["review_requests", "node_id"],
|
|
2510
|
-
["approved_versions", "node_id"],
|
|
2511
|
-
["annotation_threads", "node_id"],
|
|
2512
|
-
["comments", "node_id"],
|
|
2513
|
-
["node_tag_index", "node_id"]
|
|
2514
|
-
]) {
|
|
2654
|
+
await storage.deleteDocument(nodeId);
|
|
2655
|
+
const oldDerived = derivedIdOf(nodeId);
|
|
2656
|
+
const newDerived = derivedIdOf(newId);
|
|
2657
|
+
const db = getDb();
|
|
2658
|
+
await db.transaction(async (tx) => {
|
|
2659
|
+
await rewriteNodeKeyedRows(tx, nestId, nodeId, newId);
|
|
2660
|
+
await rewriteNodeKeyedRows(tx, nestId, oldDerived, newDerived);
|
|
2515
2661
|
await tx.run(
|
|
2516
|
-
|
|
2662
|
+
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
2517
2663
|
[newId, nestId, nodeId]
|
|
2518
2664
|
);
|
|
2519
2665
|
await tx.run(
|
|
2520
|
-
`UPDATE
|
|
2521
|
-
|
|
2666
|
+
`UPDATE run_steps SET node_id = ?
|
|
2667
|
+
WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
|
|
2668
|
+
[newId, nodeId, nestId]
|
|
2522
2669
|
);
|
|
2670
|
+
});
|
|
2671
|
+
try {
|
|
2672
|
+
const derivedDoc = await storage.readDocument(oldDerived);
|
|
2673
|
+
await storage.writeDocument(
|
|
2674
|
+
newDerived,
|
|
2675
|
+
serializeNodeSafe({ ...derivedDoc, id: newDerived })
|
|
2676
|
+
);
|
|
2677
|
+
await storage.deleteDocument(oldDerived);
|
|
2678
|
+
} catch {
|
|
2523
2679
|
}
|
|
2524
|
-
await
|
|
2525
|
-
|
|
2526
|
-
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2527
|
-
[newId, nestId, nodeId]
|
|
2528
|
-
);
|
|
2529
|
-
await tx.run(
|
|
2530
|
-
`UPDATE grants SET target = ?
|
|
2531
|
-
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2532
|
-
[newId, nestId, nodeId]
|
|
2533
|
-
);
|
|
2534
|
-
await tx.run(
|
|
2535
|
-
"UPDATE edges SET from_node = ? WHERE nest_id = ? AND from_node = ?",
|
|
2536
|
-
[newId, nestId, nodeId]
|
|
2537
|
-
);
|
|
2538
|
-
await tx.run(
|
|
2539
|
-
"UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
|
|
2540
|
-
[newId, nestId, nodeId]
|
|
2541
|
-
);
|
|
2542
|
-
await tx.run(
|
|
2543
|
-
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
2544
|
-
[newId, nestId, nodeId]
|
|
2545
|
-
);
|
|
2546
|
-
await tx.run(
|
|
2547
|
-
`UPDATE run_steps SET node_id = ?
|
|
2548
|
-
WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
|
|
2549
|
-
[newId, nodeId, nestId]
|
|
2550
|
-
);
|
|
2680
|
+
await trackEvent("node.move", { nestId, nodeId, newId });
|
|
2681
|
+
return { node: moved, previousId: nodeId };
|
|
2551
2682
|
});
|
|
2683
|
+
}
|
|
2684
|
+
async function deleteNode(nestId, nodeId, userEmail) {
|
|
2685
|
+
const { storage } = await engineCache.get(nestId);
|
|
2686
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
|
|
2552
2687
|
try {
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2688
|
+
await storage.deleteDocument(nodeId);
|
|
2689
|
+
} catch {
|
|
2690
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2691
|
+
}
|
|
2692
|
+
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2693
|
+
const derivedId = derivedIdOf(nodeId);
|
|
2694
|
+
try {
|
|
2695
|
+
await storage.deleteDocument(derivedId);
|
|
2696
|
+
await removeNodeFromTagIndex(nestId, derivedId);
|
|
2559
2697
|
} catch {
|
|
2560
2698
|
}
|
|
2561
|
-
|
|
2562
|
-
|
|
2699
|
+
const db = getDb();
|
|
2700
|
+
await db.transaction(async (tx) => {
|
|
2701
|
+
await purgeNodeKeyedRows(tx, nestId, nodeId);
|
|
2702
|
+
await purgeNodeKeyedRows(tx, nestId, derivedId);
|
|
2703
|
+
await tx.run(
|
|
2704
|
+
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
2705
|
+
VALUES (?, ?, ?, ?)
|
|
2706
|
+
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
2707
|
+
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
2708
|
+
[nestId, nodeId, userEmail, (/* @__PURE__ */ new Date()).toISOString()]
|
|
2709
|
+
);
|
|
2710
|
+
});
|
|
2711
|
+
await trackEvent("node.delete", { nestId, nodeId });
|
|
2712
|
+
}
|
|
2713
|
+
async function deleteFolder(nestId, folder, userEmail) {
|
|
2714
|
+
const segments = folderToSegments(folder);
|
|
2715
|
+
if (segments.length === 0) {
|
|
2716
|
+
throw new ValidationError("folder is required");
|
|
2717
|
+
}
|
|
2718
|
+
const rel = segments.join("/");
|
|
2719
|
+
const { storage } = await engineCache.get(nestId);
|
|
2720
|
+
const docs = await storage.discoverDocuments();
|
|
2721
|
+
const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
|
|
2722
|
+
if (targets.length === 0) {
|
|
2723
|
+
throw new NotFoundError(`No documents found under folder: ${folder}`);
|
|
2724
|
+
}
|
|
2725
|
+
const deleted = [];
|
|
2726
|
+
for (const id of targets) {
|
|
2727
|
+
try {
|
|
2728
|
+
await deleteNode(nestId, id, userEmail);
|
|
2729
|
+
deleted.push(id);
|
|
2730
|
+
} catch (err) {
|
|
2731
|
+
const detail = deleted.length > 0 ? ` (${deleted.length} of ${targets.length} already deleted)` : "";
|
|
2732
|
+
throw new Error(
|
|
2733
|
+
`Failed to delete "${id}"${detail}: ${err instanceof Error ? err.message : String(err)}`
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
return deleted;
|
|
2563
2738
|
}
|
|
2564
2739
|
|
|
2565
2740
|
// src/nests/unsynced-service.ts
|
|
@@ -3011,7 +3186,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
|
|
|
3011
3186
|
}
|
|
3012
3187
|
return c.json({
|
|
3013
3188
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3014
|
-
allow_self_approve: await nestAllowsSelfApprove(nestId)
|
|
3189
|
+
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
3190
|
+
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
3191
|
+
prime_tags: await nestPrimeTags(nestId)
|
|
3015
3192
|
});
|
|
3016
3193
|
});
|
|
3017
3194
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
@@ -3047,9 +3224,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
3047
3224
|
if (typeof body.allow_self_approve === "boolean") {
|
|
3048
3225
|
await setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
3049
3226
|
}
|
|
3227
|
+
if (typeof body.prime_only_review === "boolean") {
|
|
3228
|
+
await setPrimeOnlyReview(nestId, body.prime_only_review);
|
|
3229
|
+
}
|
|
3230
|
+
if (body.prime_tags !== void 0) {
|
|
3231
|
+
await setPrimeTags(
|
|
3232
|
+
nestId,
|
|
3233
|
+
Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3050
3236
|
return c.json({
|
|
3051
3237
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3052
3238
|
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
3239
|
+
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
3240
|
+
prime_tags: await nestPrimeTags(nestId),
|
|
3053
3241
|
wiped
|
|
3054
3242
|
});
|
|
3055
3243
|
});
|
|
@@ -3143,16 +3331,38 @@ async function addCollaborator(params) {
|
|
|
3143
3331
|
actor: params.email || userId,
|
|
3144
3332
|
permission: params.permission,
|
|
3145
3333
|
by: params.grantedByEmail || void 0
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
);
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3334
|
+
},
|
|
3335
|
+
// Invite email goes to the invitee directly (below), NOT the shared
|
|
3336
|
+
// NOTIFY_EMAIL_TO team inbox — so skip the team-channel email for this
|
|
3337
|
+
// event. Slack/Teams still fire.
|
|
3338
|
+
{ skipEmail: true }
|
|
3339
|
+
);
|
|
3340
|
+
const inviteeEmail = params.email || (await db.get("SELECT email FROM users WHERE id = ?", [userId]))?.email;
|
|
3341
|
+
if (inviteeEmail) {
|
|
3342
|
+
void sendEmailToRecipient(inviteeEmail, {
|
|
3343
|
+
status: "shared",
|
|
3344
|
+
docTitle: shareNestName,
|
|
3345
|
+
path: shareNestName,
|
|
3346
|
+
link: docLink(nestId, void 0, params.baseUrl),
|
|
3347
|
+
actor: inviteeEmail,
|
|
3348
|
+
permission: params.permission,
|
|
3349
|
+
by: params.grantedByEmail || void 0
|
|
3350
|
+
});
|
|
3351
|
+
void notifyNestInvite({
|
|
3352
|
+
nestId,
|
|
3353
|
+
inviteeEmail,
|
|
3354
|
+
permission: params.permission,
|
|
3355
|
+
nestName: shareNestName
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
return await db.get(
|
|
3359
|
+
"SELECT * FROM nest_collaborators WHERE id = ?",
|
|
3360
|
+
[collabId]
|
|
3361
|
+
);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
// src/nests/sharing-routes.ts
|
|
3365
|
+
var sharingRoutes = new Hono4();
|
|
3156
3366
|
sharingRoutes.get("/collaborators", async (c) => {
|
|
3157
3367
|
const db = getDb();
|
|
3158
3368
|
const nestId = c.req.param("nestId");
|
|
@@ -3336,7 +3546,8 @@ nestTeamRoutes.post("/", async (c) => {
|
|
|
3336
3546
|
nestId: c.req.param("nestId"),
|
|
3337
3547
|
teamId: body.team_id,
|
|
3338
3548
|
callerUserId: userId,
|
|
3339
|
-
allowAdmin: await isLicenseAdminUserId(userId)
|
|
3549
|
+
allowAdmin: await isLicenseAdminUserId(userId),
|
|
3550
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
3340
3551
|
});
|
|
3341
3552
|
return c.json({ teams }, 201);
|
|
3342
3553
|
});
|
|
@@ -3697,447 +3908,128 @@ ${m[1]}
|
|
|
3697
3908
|
` : "";
|
|
3698
3909
|
}
|
|
3699
3910
|
|
|
3700
|
-
// src/
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
3711
|
-
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
3712
|
-
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
3713
|
-
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
3714
|
-
}
|
|
3715
|
-
function oneLine(s) {
|
|
3716
|
-
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
3717
|
-
}
|
|
3718
|
-
function renderAnchor(anchor) {
|
|
3719
|
-
if (!anchor) {
|
|
3720
|
-
return "<!-- anchor: whole-artifact -->";
|
|
3721
|
-
}
|
|
3722
|
-
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
3723
|
-
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
3724
|
-
anchor.after
|
|
3725
|
-
)}\u2026`;
|
|
3726
|
-
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
3911
|
+
// src/nodes/routes.ts
|
|
3912
|
+
var nodeRoutes = new Hono8();
|
|
3913
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
3914
|
+
return nodeToMarkdown({
|
|
3915
|
+
id: nodeId,
|
|
3916
|
+
title: response.title,
|
|
3917
|
+
tags: response.tags,
|
|
3918
|
+
status: response.status,
|
|
3919
|
+
body: response.content
|
|
3920
|
+
});
|
|
3727
3921
|
}
|
|
3728
|
-
function
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3922
|
+
function rawNodeBody(c, response, storedRaw) {
|
|
3923
|
+
const body = response.content ?? "";
|
|
3924
|
+
const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
|
|
3925
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
3926
|
+
if ((response.type || "document") === "artifact") {
|
|
3927
|
+
applyArtifactSandboxHeaders(c);
|
|
3734
3928
|
}
|
|
3735
|
-
return
|
|
3736
|
-
|
|
3737
|
-
function snapshotLabel(v) {
|
|
3738
|
-
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
3739
|
-
}
|
|
3740
|
-
function orderThreads(threads) {
|
|
3741
|
-
return [...threads].sort((a, b) => {
|
|
3742
|
-
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
3743
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
3929
|
+
return c.body(payload, 200, {
|
|
3930
|
+
"Content-Type": rawContentType(response.type)
|
|
3744
3931
|
});
|
|
3745
3932
|
}
|
|
3746
|
-
|
|
3747
|
-
const
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
if (
|
|
3755
|
-
|
|
3756
|
-
lines.push("");
|
|
3757
|
-
return lines.join("\n");
|
|
3933
|
+
nodeRoutes.get("/", async (c) => {
|
|
3934
|
+
const nestId = c.req.param("nestId");
|
|
3935
|
+
const userId = c.get("userId");
|
|
3936
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
3937
|
+
return c.json({ count: nodes.length, nodes });
|
|
3938
|
+
});
|
|
3939
|
+
nodeRoutes.post("/", async (c) => {
|
|
3940
|
+
const body = await c.req.json();
|
|
3941
|
+
if (!body.title || !body.content) {
|
|
3942
|
+
throw new ValidationError("title and content are required");
|
|
3758
3943
|
}
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
const key = t.snapshotVersion ?? null;
|
|
3762
|
-
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
3944
|
+
if (body.folder !== void 0 && typeof body.folder !== "string") {
|
|
3945
|
+
throw new ValidationError('folder must be a string path like "gtm/deals"');
|
|
3763
3946
|
}
|
|
3764
|
-
const
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3947
|
+
const nestId = c.req.param("nestId");
|
|
3948
|
+
const authorEmail = await getUserEmail(c);
|
|
3949
|
+
const { node } = await createNode(
|
|
3950
|
+
nestId,
|
|
3951
|
+
{
|
|
3952
|
+
title: body.title,
|
|
3953
|
+
content: body.content,
|
|
3954
|
+
type: body.type,
|
|
3955
|
+
tags: body.tags,
|
|
3956
|
+
scope: body.scope,
|
|
3957
|
+
status: body.status,
|
|
3958
|
+
schedule: body.schedule,
|
|
3959
|
+
folder: body.folder,
|
|
3960
|
+
prime: body.prime
|
|
3961
|
+
},
|
|
3962
|
+
authorEmail
|
|
3963
|
+
);
|
|
3964
|
+
const resolved = await resolveStewardsForNode(nestId, node.id);
|
|
3965
|
+
return c.json({
|
|
3966
|
+
node: toNodeResponse(node),
|
|
3967
|
+
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
3968
|
+
email: r.steward.userEmail,
|
|
3969
|
+
role: r.steward.role,
|
|
3970
|
+
source: r.source
|
|
3971
|
+
})) : void 0
|
|
3972
|
+
}, 201);
|
|
3973
|
+
});
|
|
3974
|
+
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3975
|
+
const nestId = c.req.param("nestId");
|
|
3976
|
+
const nodeId = c.req.param("nodeId");
|
|
3977
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
|
|
3978
|
+
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
3979
|
+
nestId,
|
|
3980
|
+
nodeId
|
|
3981
|
+
);
|
|
3982
|
+
return c.json({
|
|
3983
|
+
nodeId,
|
|
3984
|
+
stewards: stewards.map((r) => ({
|
|
3985
|
+
email: r.steward.userEmail,
|
|
3986
|
+
role: r.steward.role,
|
|
3987
|
+
scope: r.steward.scope,
|
|
3988
|
+
source: r.source,
|
|
3989
|
+
priority: r.priority
|
|
3990
|
+
})),
|
|
3991
|
+
fallbackToOwner,
|
|
3992
|
+
ownerEmail
|
|
3768
3993
|
});
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3994
|
+
});
|
|
3995
|
+
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
3996
|
+
const nestId = c.req.param("nestId");
|
|
3997
|
+
const nodeId = c.req.param("nodeId");
|
|
3998
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
|
|
3999
|
+
const history = await getReviewHistory2(nestId, nodeId);
|
|
4000
|
+
return c.json({ reviews: history });
|
|
4001
|
+
});
|
|
4002
|
+
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
4003
|
+
const nestId = c.req.param("nestId");
|
|
4004
|
+
const nodeId = c.req.param("nodeId");
|
|
4005
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
4006
|
+
const userId = c.get("userId");
|
|
4007
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
4008
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
4009
|
+
return c.json(
|
|
4010
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
4011
|
+
403
|
|
4012
|
+
);
|
|
3782
4013
|
}
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
async function loadComments(threadId) {
|
|
3788
|
-
const db = getDb();
|
|
3789
|
-
const rows = await db.all(
|
|
3790
|
-
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
3791
|
-
[threadId]
|
|
3792
|
-
);
|
|
3793
|
-
return rows.map((r) => ({
|
|
3794
|
-
id: r.id,
|
|
3795
|
-
author: r.author,
|
|
3796
|
-
body: r.body,
|
|
3797
|
-
createdAt: r.created_at
|
|
3798
|
-
}));
|
|
3799
|
-
}
|
|
3800
|
-
async function rowToThread(row) {
|
|
3801
|
-
let anchor = null;
|
|
3802
|
-
if (row.anchor_json) {
|
|
3803
|
-
try {
|
|
3804
|
-
anchor = JSON.parse(row.anchor_json);
|
|
3805
|
-
} catch {
|
|
3806
|
-
anchor = null;
|
|
3807
|
-
}
|
|
4014
|
+
const body = await c.req.json().catch(() => ({}));
|
|
4015
|
+
const targetVersion = Number(body.targetVersion);
|
|
4016
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
4017
|
+
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
3808
4018
|
}
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
async function getThreadRow(threadId) {
|
|
3824
|
-
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
3825
|
-
}
|
|
3826
|
-
async function countThreadsByNode(nestId) {
|
|
3827
|
-
const rows = await getDb().all(
|
|
3828
|
-
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
3829
|
-
FROM annotation_threads
|
|
3830
|
-
WHERE nest_id = ?
|
|
3831
|
-
GROUP BY node_id`,
|
|
3832
|
-
[nestId]
|
|
3833
|
-
);
|
|
3834
|
-
const out = {};
|
|
3835
|
-
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
3836
|
-
return out;
|
|
3837
|
-
}
|
|
3838
|
-
async function listThreads(nestId, nodeId) {
|
|
3839
|
-
const rows = await getDb().all(
|
|
3840
|
-
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
3841
|
-
[nestId, nodeId]
|
|
3842
|
-
);
|
|
3843
|
-
return Promise.all(rows.map(rowToThread));
|
|
3844
|
-
}
|
|
3845
|
-
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
3846
|
-
const body = (input.body ?? "").trim();
|
|
3847
|
-
if (!body) {
|
|
3848
|
-
throw new Error("comment body is required");
|
|
3849
|
-
}
|
|
3850
|
-
const db = getDb();
|
|
3851
|
-
const id = uuid5();
|
|
3852
|
-
const anchor = clampAnchor(input.anchor);
|
|
3853
|
-
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3854
|
-
await db.transaction(async (tx) => {
|
|
3855
|
-
await tx.run(
|
|
3856
|
-
`INSERT INTO annotation_threads
|
|
3857
|
-
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
3858
|
-
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
3859
|
-
[
|
|
3860
|
-
id,
|
|
3861
|
-
nestId,
|
|
3862
|
-
nodeId,
|
|
3863
|
-
snapshot,
|
|
3864
|
-
anchor ? JSON.stringify(anchor) : null,
|
|
3865
|
-
authorEmail
|
|
3866
|
-
]
|
|
3867
|
-
);
|
|
3868
|
-
await tx.run(
|
|
3869
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3870
|
-
[uuid5(), id, authorEmail, body]
|
|
3871
|
-
);
|
|
3872
|
-
});
|
|
3873
|
-
return rowToThread(await getThreadRow(id));
|
|
3874
|
-
}
|
|
3875
|
-
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
3876
|
-
const row = await getThreadRow(threadId);
|
|
3877
|
-
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
3878
|
-
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
3879
|
-
}
|
|
3880
|
-
return row;
|
|
3881
|
-
}
|
|
3882
|
-
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
3883
|
-
const trimmed = (body ?? "").trim();
|
|
3884
|
-
if (!trimmed) {
|
|
3885
|
-
throw new Error("comment body is required");
|
|
3886
|
-
}
|
|
3887
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3888
|
-
await getDb().run(
|
|
3889
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3890
|
-
[uuid5(), threadId, authorEmail, trimmed]
|
|
3891
|
-
);
|
|
3892
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3893
|
-
}
|
|
3894
|
-
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
3895
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3896
|
-
const db = getDb();
|
|
3897
|
-
if (status === "resolved") {
|
|
3898
|
-
await db.run(
|
|
3899
|
-
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
3900
|
-
[byEmail, threadId]
|
|
3901
|
-
);
|
|
3902
|
-
} else {
|
|
3903
|
-
await db.run(
|
|
3904
|
-
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
3905
|
-
[threadId]
|
|
3906
|
-
);
|
|
3907
|
-
}
|
|
3908
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3909
|
-
}
|
|
3910
|
-
async function readArtifact(nestId, nodeId, version) {
|
|
3911
|
-
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
3912
|
-
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3913
|
-
let title = nodeId;
|
|
3914
|
-
let type = null;
|
|
3915
|
-
let liveHtml = "";
|
|
3916
|
-
let liveExists = true;
|
|
3917
|
-
try {
|
|
3918
|
-
const node = await storage.readDocument(nodeId);
|
|
3919
|
-
title = node.frontmatter?.title || nodeId;
|
|
3920
|
-
type = node.frontmatter?.type || null;
|
|
3921
|
-
liveHtml = node.body || "";
|
|
3922
|
-
} catch {
|
|
3923
|
-
liveExists = false;
|
|
3924
|
-
}
|
|
3925
|
-
if (target != null) {
|
|
3926
|
-
try {
|
|
3927
|
-
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
3928
|
-
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
3929
|
-
} catch (err) {
|
|
3930
|
-
console.warn(
|
|
3931
|
-
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
3932
|
-
err
|
|
3933
|
-
);
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
if (!liveExists) {
|
|
3937
|
-
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
3938
|
-
}
|
|
3939
|
-
return { title, html: liveHtml, version: target, type };
|
|
3940
|
-
}
|
|
3941
|
-
function derivedAnnotationsId(sourceNodeId) {
|
|
3942
|
-
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
3943
|
-
return `nodes/${base}--annotations`;
|
|
3944
|
-
}
|
|
3945
|
-
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
3946
|
-
try {
|
|
3947
|
-
let title = nodeId;
|
|
3948
|
-
let type = null;
|
|
3949
|
-
try {
|
|
3950
|
-
const { storage } = await engineCache.get(nestId);
|
|
3951
|
-
const node = await storage.readDocument(nodeId);
|
|
3952
|
-
title = node.frontmatter?.title || nodeId;
|
|
3953
|
-
type = node.frontmatter?.type || "document";
|
|
3954
|
-
} catch {
|
|
3955
|
-
}
|
|
3956
|
-
if (type !== null && type !== "artifact") return;
|
|
3957
|
-
const threads = await listThreads(nestId, nodeId);
|
|
3958
|
-
const derivedTitle = `${title} \u2014 Annotations`;
|
|
3959
|
-
const markdown = projectThreadsToMarkdown(title, threads);
|
|
3960
|
-
const derivedId = derivedAnnotationsId(nodeId);
|
|
3961
|
-
try {
|
|
3962
|
-
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
3963
|
-
} catch (err) {
|
|
3964
|
-
if (err instanceof NotFoundError) {
|
|
3965
|
-
await createNode(
|
|
3966
|
-
nestId,
|
|
3967
|
-
{
|
|
3968
|
-
id: derivedId,
|
|
3969
|
-
title: derivedTitle,
|
|
3970
|
-
content: markdown,
|
|
3971
|
-
type: "document",
|
|
3972
|
-
tags: ["annotations"]
|
|
3973
|
-
},
|
|
3974
|
-
userEmail
|
|
3975
|
-
);
|
|
3976
|
-
} else {
|
|
3977
|
-
throw err;
|
|
3978
|
-
}
|
|
3979
|
-
}
|
|
3980
|
-
} catch (err) {
|
|
3981
|
-
console.warn(
|
|
3982
|
-
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
3983
|
-
err
|
|
3984
|
-
);
|
|
3985
|
-
}
|
|
3986
|
-
}
|
|
3987
|
-
|
|
3988
|
-
// src/nodes/routes.ts
|
|
3989
|
-
var nodeRoutes = new Hono8();
|
|
3990
|
-
function nodeAsMarkdown(response, nodeId) {
|
|
3991
|
-
return nodeToMarkdown({
|
|
3992
|
-
id: nodeId,
|
|
3993
|
-
title: response.title,
|
|
3994
|
-
tags: response.tags,
|
|
3995
|
-
status: response.status,
|
|
3996
|
-
body: response.content
|
|
3997
|
-
});
|
|
3998
|
-
}
|
|
3999
|
-
function rawNodeBody(c, response, storedRaw) {
|
|
4000
|
-
const body = response.content ?? "";
|
|
4001
|
-
const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
|
|
4002
|
-
c.header("X-Content-Type-Options", "nosniff");
|
|
4003
|
-
if ((response.type || "document") === "artifact") {
|
|
4004
|
-
applyArtifactSandboxHeaders(c);
|
|
4005
|
-
}
|
|
4006
|
-
return c.body(payload, 200, {
|
|
4007
|
-
"Content-Type": rawContentType(response.type)
|
|
4008
|
-
});
|
|
4009
|
-
}
|
|
4010
|
-
nodeRoutes.get("/", async (c) => {
|
|
4011
|
-
const nestId = c.req.param("nestId");
|
|
4012
|
-
const userId = c.get("userId");
|
|
4013
|
-
const nodes = await listNodesForCaller(nestId, userId);
|
|
4014
|
-
return c.json({ count: nodes.length, nodes });
|
|
4015
|
-
});
|
|
4016
|
-
nodeRoutes.post("/", async (c) => {
|
|
4017
|
-
const body = await c.req.json();
|
|
4018
|
-
if (!body.title || !body.content) {
|
|
4019
|
-
throw new ValidationError("title and content are required");
|
|
4020
|
-
}
|
|
4021
|
-
if (body.folder !== void 0 && typeof body.folder !== "string") {
|
|
4022
|
-
throw new ValidationError('folder must be a string path like "gtm/deals"');
|
|
4023
|
-
}
|
|
4024
|
-
const nestId = c.req.param("nestId");
|
|
4025
|
-
const authorEmail = await getUserEmail(c);
|
|
4026
|
-
const { node } = await createNode(
|
|
4027
|
-
nestId,
|
|
4028
|
-
{
|
|
4029
|
-
title: body.title,
|
|
4030
|
-
content: body.content,
|
|
4031
|
-
type: body.type,
|
|
4032
|
-
tags: body.tags,
|
|
4033
|
-
scope: body.scope,
|
|
4034
|
-
status: body.status,
|
|
4035
|
-
schedule: body.schedule,
|
|
4036
|
-
folder: body.folder
|
|
4037
|
-
},
|
|
4038
|
-
authorEmail
|
|
4039
|
-
);
|
|
4040
|
-
const resolved = await resolveStewardsForNode(nestId, node.id);
|
|
4041
|
-
return c.json({
|
|
4042
|
-
node: toNodeResponse(node),
|
|
4043
|
-
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
4044
|
-
email: r.steward.userEmail,
|
|
4045
|
-
role: r.steward.role,
|
|
4046
|
-
source: r.source
|
|
4047
|
-
})) : void 0
|
|
4048
|
-
}, 201);
|
|
4049
|
-
});
|
|
4050
|
-
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
4051
|
-
const nestId = c.req.param("nestId");
|
|
4052
|
-
const nodeId = c.req.param("nodeId");
|
|
4053
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-B6L7UACX.js");
|
|
4054
|
-
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
4055
|
-
nestId,
|
|
4056
|
-
nodeId
|
|
4057
|
-
);
|
|
4058
|
-
return c.json({
|
|
4059
|
-
nodeId,
|
|
4060
|
-
stewards: stewards.map((r) => ({
|
|
4061
|
-
email: r.steward.userEmail,
|
|
4062
|
-
role: r.steward.role,
|
|
4063
|
-
scope: r.steward.scope,
|
|
4064
|
-
source: r.source,
|
|
4065
|
-
priority: r.priority
|
|
4066
|
-
})),
|
|
4067
|
-
fallbackToOwner,
|
|
4068
|
-
ownerEmail
|
|
4069
|
-
});
|
|
4070
|
-
});
|
|
4071
|
-
nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
4072
|
-
const nestId = c.req.param("nestId");
|
|
4073
|
-
const nodeId = c.req.param("nodeId");
|
|
4074
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-L52F4WVZ.js");
|
|
4075
|
-
const allVersions = await getVersions2(nestId, nodeId);
|
|
4076
|
-
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
4077
|
-
const db = getDb();
|
|
4078
|
-
const resolutions = await db.all(
|
|
4079
|
-
`SELECT version, status, resolved_by, resolved_at
|
|
4080
|
-
FROM review_requests
|
|
4081
|
-
WHERE nest_id = ? AND node_id = ?
|
|
4082
|
-
AND status IN ('approved', 'rejected')
|
|
4083
|
-
AND resolved_by IS NOT NULL
|
|
4084
|
-
ORDER BY resolved_at DESC`,
|
|
4085
|
-
[nestId, nodeId]
|
|
4086
|
-
);
|
|
4087
|
-
const byVersion = /* @__PURE__ */ new Map();
|
|
4088
|
-
for (const r of resolutions) {
|
|
4089
|
-
if (!byVersion.has(r.version)) {
|
|
4090
|
-
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
4091
|
-
}
|
|
4092
|
-
}
|
|
4093
|
-
const enriched = allVersions.map((v) => {
|
|
4094
|
-
const r = byVersion.get(v.version);
|
|
4095
|
-
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
4096
|
-
});
|
|
4097
|
-
return c.json({
|
|
4098
|
-
versions: enriched,
|
|
4099
|
-
approvedVersion: approved,
|
|
4100
|
-
currentVersion: allVersions[0]?.version || 0
|
|
4101
|
-
});
|
|
4102
|
-
});
|
|
4103
|
-
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
4104
|
-
const nestId = c.req.param("nestId");
|
|
4105
|
-
const nodeId = c.req.param("nodeId");
|
|
4106
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-Q3NDK5TX.js");
|
|
4107
|
-
const history = await getReviewHistory2(nestId, nodeId);
|
|
4108
|
-
return c.json({ reviews: history });
|
|
4109
|
-
});
|
|
4110
|
-
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
4111
|
-
const nestId = c.req.param("nestId");
|
|
4112
|
-
const nodeId = c.req.param("nodeId");
|
|
4113
|
-
const { versions: versionManager } = await engineCache.get(nestId);
|
|
4114
|
-
const userId = c.get("userId");
|
|
4115
|
-
const userEmail = await resolveCallerEmail(userId);
|
|
4116
|
-
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
4117
|
-
return c.json(
|
|
4118
|
-
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
4119
|
-
403
|
|
4120
|
-
);
|
|
4121
|
-
}
|
|
4122
|
-
const body = await c.req.json().catch(() => ({}));
|
|
4123
|
-
const targetVersion = Number(body.targetVersion);
|
|
4124
|
-
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
4125
|
-
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
4126
|
-
}
|
|
4127
|
-
let raw;
|
|
4128
|
-
try {
|
|
4129
|
-
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
4130
|
-
} catch {
|
|
4131
|
-
throw new NotFoundError(
|
|
4132
|
-
`Version ${targetVersion} not found for ${nodeId}`
|
|
4133
|
-
);
|
|
4134
|
-
}
|
|
4135
|
-
const content = bodyOnly(nodeId, raw);
|
|
4136
|
-
const { node, version } = await updateNode(
|
|
4137
|
-
nestId,
|
|
4138
|
-
nodeId,
|
|
4139
|
-
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
4140
|
-
userEmail
|
|
4019
|
+
let raw;
|
|
4020
|
+
try {
|
|
4021
|
+
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
4022
|
+
} catch {
|
|
4023
|
+
throw new NotFoundError(
|
|
4024
|
+
`Version ${targetVersion} not found for ${nodeId}`
|
|
4025
|
+
);
|
|
4026
|
+
}
|
|
4027
|
+
const content = bodyOnly(nodeId, raw);
|
|
4028
|
+
const { node, version } = await updateNode(
|
|
4029
|
+
nestId,
|
|
4030
|
+
nodeId,
|
|
4031
|
+
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
4032
|
+
userEmail
|
|
4141
4033
|
);
|
|
4142
4034
|
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
4143
4035
|
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
@@ -4269,6 +4161,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
4269
4161
|
const pending = await getPendingReview(nestId, nodeId);
|
|
4270
4162
|
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
4271
4163
|
}
|
|
4164
|
+
response.deletionRequest = await getLatestDeletionRequest(
|
|
4165
|
+
nestId,
|
|
4166
|
+
nodeId
|
|
4167
|
+
);
|
|
4272
4168
|
if (isRawFormat(c)) {
|
|
4273
4169
|
return rawNodeBody(c, response, node.rawContent);
|
|
4274
4170
|
}
|
|
@@ -4312,7 +4208,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
4312
4208
|
title: body.title,
|
|
4313
4209
|
status: body.status,
|
|
4314
4210
|
changeNote: body.changeNote,
|
|
4315
|
-
schedule: body.schedule
|
|
4211
|
+
schedule: body.schedule,
|
|
4212
|
+
prime: body.prime
|
|
4316
4213
|
},
|
|
4317
4214
|
authorEmail
|
|
4318
4215
|
);
|
|
@@ -4321,84 +4218,307 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
4321
4218
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
4322
4219
|
const nestId = c.req.param("nestId");
|
|
4323
4220
|
const nodeId = c.req.param("nodeId");
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4221
|
+
await deleteNode(nestId, nodeId, await getUserEmail(c));
|
|
4222
|
+
return c.json({ deleted: true });
|
|
4223
|
+
});
|
|
4224
|
+
async function getUserEmail(c) {
|
|
4225
|
+
const userId = c.get("userId");
|
|
4226
|
+
const db = getDb();
|
|
4227
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4228
|
+
return user?.email || "anonymous@localhost";
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
// src/annotations/routes.ts
|
|
4232
|
+
import { Hono as Hono9 } from "hono";
|
|
4233
|
+
|
|
4234
|
+
// src/annotations/service.ts
|
|
4235
|
+
import { v4 as uuid5 } from "uuid";
|
|
4236
|
+
|
|
4237
|
+
// src/annotations/projection.ts
|
|
4238
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
4239
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
4240
|
+
function clampAnchor(raw) {
|
|
4241
|
+
if (!raw) return null;
|
|
4242
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
4243
|
+
if (!quote) return null;
|
|
4244
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
4245
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
4246
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
4247
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
4248
|
+
}
|
|
4249
|
+
function oneLine(s) {
|
|
4250
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
4251
|
+
}
|
|
4252
|
+
function renderAnchor(anchor) {
|
|
4253
|
+
if (!anchor) {
|
|
4254
|
+
return "<!-- anchor: whole-artifact -->";
|
|
4255
|
+
}
|
|
4256
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
4257
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
4258
|
+
anchor.after
|
|
4259
|
+
)}\u2026`;
|
|
4260
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
4261
|
+
}
|
|
4262
|
+
function statusHeading(thread) {
|
|
4263
|
+
if (thread.status === "resolved") {
|
|
4264
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
4265
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
4266
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
4267
|
+
return `\u2705 RESOLVED${meta}`;
|
|
4268
|
+
}
|
|
4269
|
+
return "\u{1F7E0} OPEN";
|
|
4270
|
+
}
|
|
4271
|
+
function snapshotLabel(v) {
|
|
4272
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
4273
|
+
}
|
|
4274
|
+
function orderThreads(threads) {
|
|
4275
|
+
return [...threads].sort((a, b) => {
|
|
4276
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
4277
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
4280
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
4281
|
+
const lines = [];
|
|
4282
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
4283
|
+
lines.push("");
|
|
4284
|
+
lines.push(
|
|
4285
|
+
"> 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)."
|
|
4286
|
+
);
|
|
4287
|
+
lines.push("");
|
|
4288
|
+
if (threads.length === 0) {
|
|
4289
|
+
lines.push("_No annotations yet._");
|
|
4290
|
+
lines.push("");
|
|
4291
|
+
return lines.join("\n");
|
|
4292
|
+
}
|
|
4293
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
4294
|
+
for (const t of threads) {
|
|
4295
|
+
const key = t.snapshotVersion ?? null;
|
|
4296
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
4297
|
+
}
|
|
4298
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
4299
|
+
if (a == null) return 1;
|
|
4300
|
+
if (b == null) return -1;
|
|
4301
|
+
return b - a;
|
|
4302
|
+
});
|
|
4303
|
+
for (const key of keys) {
|
|
4304
|
+
lines.push(`## ${snapshotLabel(key)}`);
|
|
4305
|
+
lines.push("");
|
|
4306
|
+
for (const thread of orderThreads(buckets2.get(key))) {
|
|
4307
|
+
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
4308
|
+
lines.push(renderAnchor(thread.anchor));
|
|
4309
|
+
for (const comment of thread.comments) {
|
|
4310
|
+
lines.push(
|
|
4311
|
+
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
4312
|
+
);
|
|
4313
|
+
}
|
|
4314
|
+
lines.push("");
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
return lines.join("\n");
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
// src/annotations/service.ts
|
|
4321
|
+
async function loadComments(threadId) {
|
|
4322
|
+
const db = getDb();
|
|
4323
|
+
const rows = await db.all(
|
|
4324
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
4325
|
+
[threadId]
|
|
4326
|
+
);
|
|
4327
|
+
return rows.map((r) => ({
|
|
4328
|
+
id: r.id,
|
|
4329
|
+
author: r.author,
|
|
4330
|
+
body: r.body,
|
|
4331
|
+
createdAt: r.created_at
|
|
4332
|
+
}));
|
|
4333
|
+
}
|
|
4334
|
+
async function rowToThread(row) {
|
|
4335
|
+
let anchor = null;
|
|
4336
|
+
if (row.anchor_json) {
|
|
4337
|
+
try {
|
|
4338
|
+
anchor = JSON.parse(row.anchor_json);
|
|
4339
|
+
} catch {
|
|
4340
|
+
anchor = null;
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
return {
|
|
4344
|
+
id: row.id,
|
|
4345
|
+
nestId: row.nest_id,
|
|
4346
|
+
nodeId: row.node_id,
|
|
4347
|
+
snapshotVersion: row.snapshot_version,
|
|
4348
|
+
anchor,
|
|
4349
|
+
status: row.status,
|
|
4350
|
+
createdBy: row.created_by,
|
|
4351
|
+
createdAt: row.created_at,
|
|
4352
|
+
resolvedBy: row.resolved_by,
|
|
4353
|
+
resolvedAt: row.resolved_at,
|
|
4354
|
+
comments: await loadComments(row.id)
|
|
4355
|
+
};
|
|
4356
|
+
}
|
|
4357
|
+
async function getThreadRow(threadId) {
|
|
4358
|
+
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
4359
|
+
}
|
|
4360
|
+
async function countThreadsByNode(nestId) {
|
|
4361
|
+
const rows = await getDb().all(
|
|
4362
|
+
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
4363
|
+
FROM annotation_threads
|
|
4364
|
+
WHERE nest_id = ?
|
|
4365
|
+
GROUP BY node_id`,
|
|
4366
|
+
[nestId]
|
|
4367
|
+
);
|
|
4368
|
+
const out = {};
|
|
4369
|
+
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
4370
|
+
return out;
|
|
4371
|
+
}
|
|
4372
|
+
async function listThreads(nestId, nodeId) {
|
|
4373
|
+
const rows = await getDb().all(
|
|
4374
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
4375
|
+
[nestId, nodeId]
|
|
4376
|
+
);
|
|
4377
|
+
return Promise.all(rows.map(rowToThread));
|
|
4378
|
+
}
|
|
4379
|
+
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
4380
|
+
const body = (input.body ?? "").trim();
|
|
4381
|
+
if (!body) {
|
|
4382
|
+
throw new Error("comment body is required");
|
|
4383
|
+
}
|
|
4384
|
+
const db = getDb();
|
|
4385
|
+
const id = uuid5();
|
|
4386
|
+
const anchor = clampAnchor(input.anchor);
|
|
4387
|
+
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
4388
|
+
await db.transaction(async (tx) => {
|
|
4389
|
+
await tx.run(
|
|
4390
|
+
`INSERT INTO annotation_threads
|
|
4391
|
+
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
4392
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
4393
|
+
[
|
|
4394
|
+
id,
|
|
4395
|
+
nestId,
|
|
4396
|
+
nodeId,
|
|
4397
|
+
snapshot,
|
|
4398
|
+
anchor ? JSON.stringify(anchor) : null,
|
|
4399
|
+
authorEmail
|
|
4400
|
+
]
|
|
4401
|
+
);
|
|
4402
|
+
await tx.run(
|
|
4403
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
4404
|
+
[uuid5(), id, authorEmail, body]
|
|
4405
|
+
);
|
|
4406
|
+
});
|
|
4407
|
+
return rowToThread(await getThreadRow(id));
|
|
4408
|
+
}
|
|
4409
|
+
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
4410
|
+
const row = await getThreadRow(threadId);
|
|
4411
|
+
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
4412
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
4413
|
+
}
|
|
4414
|
+
return row;
|
|
4415
|
+
}
|
|
4416
|
+
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
4417
|
+
const trimmed = (body ?? "").trim();
|
|
4418
|
+
if (!trimmed) {
|
|
4419
|
+
throw new Error("comment body is required");
|
|
4420
|
+
}
|
|
4421
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
4422
|
+
await getDb().run(
|
|
4423
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
4424
|
+
[uuid5(), threadId, authorEmail, trimmed]
|
|
4425
|
+
);
|
|
4426
|
+
return rowToThread(await getThreadRow(threadId));
|
|
4427
|
+
}
|
|
4428
|
+
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
4429
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
4430
|
+
const db = getDb();
|
|
4431
|
+
if (status === "resolved") {
|
|
4432
|
+
await db.run(
|
|
4433
|
+
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
4434
|
+
[byEmail, threadId]
|
|
4435
|
+
);
|
|
4436
|
+
} else {
|
|
4437
|
+
await db.run(
|
|
4438
|
+
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
4439
|
+
[threadId]
|
|
4328
4440
|
);
|
|
4329
4441
|
}
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4442
|
+
return rowToThread(await getThreadRow(threadId));
|
|
4443
|
+
}
|
|
4444
|
+
async function readArtifact(nestId, nodeId, version) {
|
|
4445
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
4446
|
+
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
4447
|
+
let title = nodeId;
|
|
4448
|
+
let type = null;
|
|
4449
|
+
let liveHtml = "";
|
|
4450
|
+
let liveExists = true;
|
|
4451
|
+
try {
|
|
4452
|
+
const node = await storage.readDocument(nodeId);
|
|
4453
|
+
title = node.frontmatter?.title || nodeId;
|
|
4454
|
+
type = node.frontmatter?.type || null;
|
|
4455
|
+
liveHtml = node.body || "";
|
|
4456
|
+
} catch {
|
|
4457
|
+
liveExists = false;
|
|
4458
|
+
}
|
|
4459
|
+
if (target != null) {
|
|
4460
|
+
try {
|
|
4461
|
+
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
4462
|
+
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
4463
|
+
} catch (err) {
|
|
4464
|
+
console.warn(
|
|
4465
|
+
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
4466
|
+
err
|
|
4467
|
+
);
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
if (!liveExists) {
|
|
4471
|
+
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
4334
4472
|
}
|
|
4335
|
-
|
|
4336
|
-
|
|
4473
|
+
return { title, html: liveHtml, version: target, type };
|
|
4474
|
+
}
|
|
4475
|
+
function derivedAnnotationsId(sourceNodeId) {
|
|
4476
|
+
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
4477
|
+
return `nodes/${base}--annotations`;
|
|
4478
|
+
}
|
|
4479
|
+
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
4337
4480
|
try {
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
);
|
|
4348
|
-
await
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
);
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
[nestId, derivedId]
|
|
4376
|
-
);
|
|
4377
|
-
await tx.run(
|
|
4378
|
-
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
4379
|
-
[nestId, derivedId]
|
|
4380
|
-
);
|
|
4381
|
-
await tx.run(
|
|
4382
|
-
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
4383
|
-
VALUES (?, ?, ?, ?)
|
|
4384
|
-
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
4385
|
-
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
4386
|
-
[nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
|
|
4481
|
+
let title = nodeId;
|
|
4482
|
+
let type = null;
|
|
4483
|
+
try {
|
|
4484
|
+
const { storage } = await engineCache.get(nestId);
|
|
4485
|
+
const node = await storage.readDocument(nodeId);
|
|
4486
|
+
title = node.frontmatter?.title || nodeId;
|
|
4487
|
+
type = node.frontmatter?.type || "document";
|
|
4488
|
+
} catch {
|
|
4489
|
+
}
|
|
4490
|
+
if (type !== null && type !== "artifact") return;
|
|
4491
|
+
const threads = await listThreads(nestId, nodeId);
|
|
4492
|
+
const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
|
|
4493
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
4494
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
4495
|
+
try {
|
|
4496
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
4497
|
+
} catch (err) {
|
|
4498
|
+
if (err instanceof NotFoundError) {
|
|
4499
|
+
await createNode(
|
|
4500
|
+
nestId,
|
|
4501
|
+
{
|
|
4502
|
+
id: derivedId,
|
|
4503
|
+
title: derivedTitle,
|
|
4504
|
+
content: markdown,
|
|
4505
|
+
type: "document",
|
|
4506
|
+
tags: ["annotations"]
|
|
4507
|
+
},
|
|
4508
|
+
userEmail
|
|
4509
|
+
);
|
|
4510
|
+
} else {
|
|
4511
|
+
throw err;
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
} catch (err) {
|
|
4515
|
+
console.warn(
|
|
4516
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
4517
|
+
err
|
|
4387
4518
|
);
|
|
4388
|
-
}
|
|
4389
|
-
await trackEvent("node.delete", { nestId, nodeId });
|
|
4390
|
-
return c.json({ deleted: true });
|
|
4391
|
-
});
|
|
4392
|
-
async function getUserEmail(c) {
|
|
4393
|
-
const userId = c.get("userId");
|
|
4394
|
-
const db = getDb();
|
|
4395
|
-
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4396
|
-
return user?.email || "anonymous@localhost";
|
|
4519
|
+
}
|
|
4397
4520
|
}
|
|
4398
4521
|
|
|
4399
|
-
// src/annotations/routes.ts
|
|
4400
|
-
import { Hono as Hono9 } from "hono";
|
|
4401
|
-
|
|
4402
4522
|
// src/annotations/types.ts
|
|
4403
4523
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
4404
4524
|
|
|
@@ -4518,7 +4638,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
|
4518
4638
|
|
|
4519
4639
|
// src/nodes/query-routes.ts
|
|
4520
4640
|
import { Hono as Hono10 } from "hono";
|
|
4521
|
-
import { serializeDocument as
|
|
4641
|
+
import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
|
|
4522
4642
|
|
|
4523
4643
|
// src/tables/service.ts
|
|
4524
4644
|
function splitCsvLine(line) {
|
|
@@ -4937,6 +5057,68 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
|
4937
5057
|
}
|
|
4938
5058
|
}
|
|
4939
5059
|
|
|
5060
|
+
// src/nests/bundle-service.ts
|
|
5061
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
5062
|
+
import { join as join4, sep } from "path";
|
|
5063
|
+
import JSZip from "jszip";
|
|
5064
|
+
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
5065
|
+
function walkFiles(root) {
|
|
5066
|
+
const out = [];
|
|
5067
|
+
let entries;
|
|
5068
|
+
try {
|
|
5069
|
+
entries = readdirSync2(root, { recursive: true });
|
|
5070
|
+
} catch {
|
|
5071
|
+
return out;
|
|
5072
|
+
}
|
|
5073
|
+
for (const rel of entries) {
|
|
5074
|
+
try {
|
|
5075
|
+
if (statSync2(join4(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
|
|
5076
|
+
} catch {
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
return out;
|
|
5080
|
+
}
|
|
5081
|
+
async function exportNestBundle(nestId, includeDrafts) {
|
|
5082
|
+
const db = getDb();
|
|
5083
|
+
const approvedRows = await db.all(
|
|
5084
|
+
"SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
|
|
5085
|
+
[nestId]
|
|
5086
|
+
);
|
|
5087
|
+
const approvedByNode = new Map(
|
|
5088
|
+
approvedRows.map((r) => [r.node_id, r.approved_version])
|
|
5089
|
+
);
|
|
5090
|
+
const { storage } = await engineCache.get(nestId);
|
|
5091
|
+
const docs = await storage.discoverDocuments();
|
|
5092
|
+
const zip = new JSZip();
|
|
5093
|
+
let count = 0;
|
|
5094
|
+
for (const doc of docs) {
|
|
5095
|
+
const approved = approvedByNode.get(doc.id);
|
|
5096
|
+
const frontmatter = stripUndefinedDeep({ ...doc.frontmatter });
|
|
5097
|
+
let body = doc.body || "";
|
|
5098
|
+
if (approved != null) {
|
|
5099
|
+
const approvedBody = await resolveExportBody(nestId, doc.id, body);
|
|
5100
|
+
if (approvedBody == null) continue;
|
|
5101
|
+
body = approvedBody;
|
|
5102
|
+
frontmatter.status = "published";
|
|
5103
|
+
frontmatter.version = approved;
|
|
5104
|
+
} else {
|
|
5105
|
+
if (!includeDrafts) continue;
|
|
5106
|
+
frontmatter.status = "draft";
|
|
5107
|
+
}
|
|
5108
|
+
zip.file(`${doc.id}.md`, serializeDocument2({ ...doc, frontmatter, body }));
|
|
5109
|
+
count++;
|
|
5110
|
+
}
|
|
5111
|
+
const nestPath = resolveNestPath(nestId);
|
|
5112
|
+
for (const rel of walkFiles(nestPath)) {
|
|
5113
|
+
const segs = rel.split("/");
|
|
5114
|
+
const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
|
|
5115
|
+
if (!keep) continue;
|
|
5116
|
+
zip.file(rel, readFileSync2(join4(nestPath, rel)));
|
|
5117
|
+
}
|
|
5118
|
+
trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
|
|
5119
|
+
return zip.generateAsync({ type: "arraybuffer" });
|
|
5120
|
+
}
|
|
5121
|
+
|
|
4940
5122
|
// src/nodes/graph-service.ts
|
|
4941
5123
|
var MAX_GRAPH_NODES = 150;
|
|
4942
5124
|
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
@@ -5490,10 +5672,24 @@ queryRoutes.get("/context", async (c) => {
|
|
|
5490
5672
|
return c.json({ content: content || "" });
|
|
5491
5673
|
});
|
|
5492
5674
|
queryRoutes.get("/export", async (c) => {
|
|
5675
|
+
const nestId = c.req.param("nestId");
|
|
5676
|
+
if (c.req.query("format") === "bundle") {
|
|
5677
|
+
const perm = await resolveNestPermission(nestId, c.get("userId"));
|
|
5678
|
+
if (perm !== "owner" && perm !== "admin") {
|
|
5679
|
+
throw new ForbiddenError("Only the nest owner or an admin can export a bundle");
|
|
5680
|
+
}
|
|
5681
|
+
const draftsParam = c.req.query("includeDrafts");
|
|
5682
|
+
const includeDrafts = draftsParam === "1" || draftsParam === "true";
|
|
5683
|
+
const zip = await exportNestBundle(nestId, includeDrafts);
|
|
5684
|
+
const filename = `nest-${nestId}.zip`;
|
|
5685
|
+
return c.body(zip, 200, {
|
|
5686
|
+
"Content-Type": "application/zip",
|
|
5687
|
+
"Content-Disposition": `attachment; filename="${filename}"`
|
|
5688
|
+
});
|
|
5689
|
+
}
|
|
5493
5690
|
if (!isMarkdownFormat(c)) {
|
|
5494
|
-
throw new ValidationError("format=markdown is required");
|
|
5691
|
+
throw new ValidationError("format=markdown or format=bundle is required");
|
|
5495
5692
|
}
|
|
5496
|
-
const nestId = c.req.param("nestId");
|
|
5497
5693
|
const { storage, query: queryEngine } = await engineCache.get(nestId);
|
|
5498
5694
|
const selector = c.req.query("selector")?.trim() || null;
|
|
5499
5695
|
let documents;
|
|
@@ -5592,7 +5788,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
5592
5788
|
body: doc.content,
|
|
5593
5789
|
rawContent: ""
|
|
5594
5790
|
};
|
|
5595
|
-
const serialized =
|
|
5791
|
+
const serialized = serializeDocument3(node);
|
|
5596
5792
|
await storage.writeDocument(id, serialized);
|
|
5597
5793
|
await syncNodeTags(nestId, id, tags);
|
|
5598
5794
|
created.push(id);
|
|
@@ -6798,7 +6994,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6798
6994
|
},
|
|
6799
6995
|
{
|
|
6800
6996
|
name: "context_create",
|
|
6801
|
-
description: "Create a new knowledge node in the vault.",
|
|
6997
|
+
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.",
|
|
6802
6998
|
inputSchema: {
|
|
6803
6999
|
type: "object",
|
|
6804
7000
|
properties: {
|
|
@@ -6819,7 +7015,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6819
7015
|
scope: { type: "string", description: "Visibility scope" },
|
|
6820
7016
|
folder: {
|
|
6821
7017
|
type: "string",
|
|
6822
|
-
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
7018
|
+
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.'
|
|
6823
7019
|
}
|
|
6824
7020
|
},
|
|
6825
7021
|
required: ["title", "content"]
|
|
@@ -6850,6 +7046,49 @@ var TOOL_DEFINITIONS = [
|
|
|
6850
7046
|
required: ["title"]
|
|
6851
7047
|
}
|
|
6852
7048
|
},
|
|
7049
|
+
{
|
|
7050
|
+
name: "context_delete",
|
|
7051
|
+
// Mirrors the engine's canonical `context_delete` operation (core
|
|
7052
|
+
// namespace, alias delete_document): same name, same {id, title} input,
|
|
7053
|
+
// same "deleted" semantics. The engine's `uri` field is omitted — this
|
|
7054
|
+
// repo has no contextnest:// resolver on any surface, so accepting one
|
|
7055
|
+
// would promise a resolution it can't do.
|
|
7056
|
+
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).",
|
|
7057
|
+
inputSchema: {
|
|
7058
|
+
type: "object",
|
|
7059
|
+
properties: {
|
|
7060
|
+
title: { type: "string", description: "Title of node to delete" },
|
|
7061
|
+
id: {
|
|
7062
|
+
type: "string",
|
|
7063
|
+
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.'
|
|
7064
|
+
}
|
|
7065
|
+
}
|
|
7066
|
+
}
|
|
7067
|
+
},
|
|
7068
|
+
{
|
|
7069
|
+
name: "context_move",
|
|
7070
|
+
// NOT in the engine's operation catalog — `move` is a community-edition
|
|
7071
|
+
// concern (the id rewrite spans governance tables the engine doesn't own).
|
|
7072
|
+
// This mirrors the existing REST POST /nodes/:id/move rather than adding a
|
|
7073
|
+
// new concept, so the surfaces agree. If move is ever promoted to a core
|
|
7074
|
+
// operation upstream, adopt that descriptor and drop this schema.
|
|
7075
|
+
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).",
|
|
7076
|
+
inputSchema: {
|
|
7077
|
+
type: "object",
|
|
7078
|
+
properties: {
|
|
7079
|
+
title: { type: "string", description: "Title of node to move" },
|
|
7080
|
+
id: {
|
|
7081
|
+
type: "string",
|
|
7082
|
+
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.'
|
|
7083
|
+
},
|
|
7084
|
+
folder: {
|
|
7085
|
+
type: "string",
|
|
7086
|
+
description: 'Destination folder under nodes/ (e.g. "gtm/deals"); segments are slugified. Use "" to move to the nest root.'
|
|
7087
|
+
}
|
|
7088
|
+
},
|
|
7089
|
+
required: ["folder"]
|
|
7090
|
+
}
|
|
7091
|
+
},
|
|
6853
7092
|
// ─── Governance Tools ───────────────────────────────────────────────
|
|
6854
7093
|
{
|
|
6855
7094
|
name: "context_stewards",
|
|
@@ -6864,6 +7103,73 @@ var TOOL_DEFINITIONS = [
|
|
|
6864
7103
|
}
|
|
6865
7104
|
}
|
|
6866
7105
|
},
|
|
7106
|
+
{
|
|
7107
|
+
// Deletion requests, mirroring the REST + UI flow: anyone who can READ a
|
|
7108
|
+
// thing may ask for it to be deleted; only an owner/admin resolves it.
|
|
7109
|
+
// Not in the engine catalog (1.2.1) — deletion requests live in this
|
|
7110
|
+
// repo's governance tables, which the engine doesn't own. Adopt an engine
|
|
7111
|
+
// descriptor here if the operation is ever promoted upstream.
|
|
7112
|
+
name: "context_request_deletion",
|
|
7113
|
+
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 declines with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
|
|
7114
|
+
inputSchema: {
|
|
7115
|
+
type: "object",
|
|
7116
|
+
properties: {
|
|
7117
|
+
title: { type: "string", description: "Title of the document to flag" },
|
|
7118
|
+
id: {
|
|
7119
|
+
type: "string",
|
|
7120
|
+
description: 'Document id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two documents share a title.'
|
|
7121
|
+
},
|
|
7122
|
+
folder: {
|
|
7123
|
+
type: "string",
|
|
7124
|
+
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.'
|
|
7125
|
+
},
|
|
7126
|
+
nest: {
|
|
7127
|
+
type: "boolean",
|
|
7128
|
+
description: "Flag the ENTIRE nest for deletion. Only an owner/admin can honour it."
|
|
7129
|
+
},
|
|
7130
|
+
reason: {
|
|
7131
|
+
type: "string",
|
|
7132
|
+
description: "Why it should be deleted. Required \u2014 this is what the admin judges."
|
|
7133
|
+
}
|
|
7134
|
+
},
|
|
7135
|
+
required: ["reason"]
|
|
7136
|
+
}
|
|
7137
|
+
},
|
|
7138
|
+
{
|
|
7139
|
+
name: "context_deletion_queue",
|
|
7140
|
+
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.",
|
|
7141
|
+
inputSchema: {
|
|
7142
|
+
type: "object",
|
|
7143
|
+
properties: {
|
|
7144
|
+
status: {
|
|
7145
|
+
type: "string",
|
|
7146
|
+
description: "pending (default) or declined \u2014 declined shows past verdicts and their notes."
|
|
7147
|
+
}
|
|
7148
|
+
}
|
|
7149
|
+
}
|
|
7150
|
+
},
|
|
7151
|
+
{
|
|
7152
|
+
name: "context_resolve_deletion",
|
|
7153
|
+
description: "Action a deletion request: delete the flagged document/folder/nest, or decline it with a note explaining why it stays. Owner/admin only. Declining tells the requester the verdict; deleting is irreversible.",
|
|
7154
|
+
inputSchema: {
|
|
7155
|
+
type: "object",
|
|
7156
|
+
properties: {
|
|
7157
|
+
request_id: {
|
|
7158
|
+
type: "string",
|
|
7159
|
+
description: "Request id from context_deletion_queue"
|
|
7160
|
+
},
|
|
7161
|
+
action: {
|
|
7162
|
+
type: "string",
|
|
7163
|
+
description: '"delete" to honour it, "decline" to keep the target'
|
|
7164
|
+
},
|
|
7165
|
+
note: {
|
|
7166
|
+
type: "string",
|
|
7167
|
+
description: "Why you declined. Required when action is decline."
|
|
7168
|
+
}
|
|
7169
|
+
},
|
|
7170
|
+
required: ["request_id", "action"]
|
|
7171
|
+
}
|
|
7172
|
+
},
|
|
6867
7173
|
{
|
|
6868
7174
|
name: "context_review_queue",
|
|
6869
7175
|
description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
|
|
@@ -6877,6 +7183,14 @@ var TOOL_DEFINITIONS = [
|
|
|
6877
7183
|
}
|
|
6878
7184
|
}
|
|
6879
7185
|
},
|
|
7186
|
+
{
|
|
7187
|
+
name: "context_my_drafts",
|
|
7188
|
+
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.",
|
|
7189
|
+
inputSchema: {
|
|
7190
|
+
type: "object",
|
|
7191
|
+
properties: {}
|
|
7192
|
+
}
|
|
7193
|
+
},
|
|
6880
7194
|
{
|
|
6881
7195
|
name: "context_submit_review",
|
|
6882
7196
|
description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
|
|
@@ -7029,6 +7343,24 @@ async function handleToolCall(toolName, args, ctx) {
|
|
|
7029
7343
|
});
|
|
7030
7344
|
}
|
|
7031
7345
|
}
|
|
7346
|
+
async function resolveTargetNode(storage, args) {
|
|
7347
|
+
if (!args.id && !args.title) return "Provide a title or an id.";
|
|
7348
|
+
if (args.id) {
|
|
7349
|
+
try {
|
|
7350
|
+
return await storage.readDocument(args.id);
|
|
7351
|
+
} catch {
|
|
7352
|
+
return `Node not found: ${args.id}`;
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
const docs = await storage.discoverDocuments();
|
|
7356
|
+
const matches = docs.filter(
|
|
7357
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7358
|
+
);
|
|
7359
|
+
if (matches.length > 1) {
|
|
7360
|
+
return `${matches.length} nodes share the title "${args.title}". Re-run with one of these ids instead: ${matches.map((n) => n.id).join(", ")}`;
|
|
7361
|
+
}
|
|
7362
|
+
return matches[0] ?? `Node not found: ${args.title}`;
|
|
7363
|
+
}
|
|
7032
7364
|
async function runTool(toolName, args, ctx) {
|
|
7033
7365
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
7034
7366
|
switch (toolName) {
|
|
@@ -7262,19 +7594,29 @@ ${sections}`;
|
|
|
7262
7594
|
if (!await canCreateInNest(nestId, userEmail)) {
|
|
7263
7595
|
return "You don't have permission to create documents in this nest.";
|
|
7264
7596
|
}
|
|
7265
|
-
const
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
}
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7597
|
+
const wanted = String(args.title ?? "").toLowerCase();
|
|
7598
|
+
return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
|
|
7599
|
+
const existingDocs = await storage.discoverDocuments();
|
|
7600
|
+
const twin = existingDocs.find(
|
|
7601
|
+
(n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
|
|
7602
|
+
);
|
|
7603
|
+
if (twin) {
|
|
7604
|
+
return `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.`;
|
|
7605
|
+
}
|
|
7606
|
+
const { node } = await createNode(
|
|
7607
|
+
nestId,
|
|
7608
|
+
{
|
|
7609
|
+
title: args.title,
|
|
7610
|
+
content: args.content,
|
|
7611
|
+
type: args.type,
|
|
7612
|
+
tags: args.tags,
|
|
7613
|
+
scope: args.scope,
|
|
7614
|
+
folder: args.folder
|
|
7615
|
+
},
|
|
7616
|
+
userEmail
|
|
7617
|
+
);
|
|
7618
|
+
return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
|
|
7619
|
+
});
|
|
7278
7620
|
}
|
|
7279
7621
|
case "context_update": {
|
|
7280
7622
|
const docs = await storage.discoverDocuments();
|
|
@@ -7298,6 +7640,39 @@ ${sections}`;
|
|
|
7298
7640
|
);
|
|
7299
7641
|
return `Updated node: **${updated.frontmatter.title}**`;
|
|
7300
7642
|
}
|
|
7643
|
+
case "context_delete": {
|
|
7644
|
+
const node = await resolveTargetNode(storage, args);
|
|
7645
|
+
if (typeof node === "string") return node;
|
|
7646
|
+
const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7647
|
+
if (!deleteCheck.allowed) {
|
|
7648
|
+
return `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
|
|
7649
|
+
|
|
7650
|
+
Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`;
|
|
7651
|
+
}
|
|
7652
|
+
await deleteNode(nestId, node.id, userEmail);
|
|
7653
|
+
return `Deleted node: **${node.frontmatter.title}** (${node.id})`;
|
|
7654
|
+
}
|
|
7655
|
+
case "context_move": {
|
|
7656
|
+
if (typeof args.folder !== "string") {
|
|
7657
|
+
return 'folder is required \u2014 a path like "gtm/deals", or "" for the nest root.';
|
|
7658
|
+
}
|
|
7659
|
+
const node = await resolveTargetNode(storage, args);
|
|
7660
|
+
if (typeof node === "string") return node;
|
|
7661
|
+
const moveCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7662
|
+
if (!moveCheck.allowed) {
|
|
7663
|
+
return `You don't have permission to move "${node.frontmatter.title}": ${moveCheck.reason}`;
|
|
7664
|
+
}
|
|
7665
|
+
const { node: moved, previousId } = await moveNode(
|
|
7666
|
+
nestId,
|
|
7667
|
+
node.id,
|
|
7668
|
+
args.folder,
|
|
7669
|
+
userEmail
|
|
7670
|
+
);
|
|
7671
|
+
if (previousId === moved.id) {
|
|
7672
|
+
return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
|
|
7673
|
+
}
|
|
7674
|
+
return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
|
|
7675
|
+
}
|
|
7301
7676
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
7302
7677
|
case "context_stewards": {
|
|
7303
7678
|
if (args.title) {
|
|
@@ -7337,6 +7712,126 @@ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ?
|
|
|
7337
7712
|
|
|
7338
7713
|
${sections}`;
|
|
7339
7714
|
}
|
|
7715
|
+
case "context_request_deletion": {
|
|
7716
|
+
const reason = typeof args.reason === "string" ? args.reason.trim() : "";
|
|
7717
|
+
if (!reason) {
|
|
7718
|
+
return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
|
|
7719
|
+
}
|
|
7720
|
+
if (args.nest === true) {
|
|
7721
|
+
const request2 = await requestDeletion({
|
|
7722
|
+
nestId,
|
|
7723
|
+
nodeId: nestId,
|
|
7724
|
+
targetType: "nest",
|
|
7725
|
+
requestedBy: userEmail,
|
|
7726
|
+
reason,
|
|
7727
|
+
baseUrl: ctx.baseUrl
|
|
7728
|
+
});
|
|
7729
|
+
return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or decline.
|
|
7730
|
+
Request id: \`${request2.id}\``;
|
|
7731
|
+
}
|
|
7732
|
+
if (typeof args.folder === "string" && args.folder.trim()) {
|
|
7733
|
+
const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
|
|
7734
|
+
const docs = await storage.discoverDocuments();
|
|
7735
|
+
const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
|
|
7736
|
+
if (inside.length === 0) {
|
|
7737
|
+
return `No folder "${folder}" \u2014 nothing is filed under it. Use context_list to see what exists.`;
|
|
7738
|
+
}
|
|
7739
|
+
const request2 = await requestDeletion({
|
|
7740
|
+
nestId,
|
|
7741
|
+
nodeId: folder,
|
|
7742
|
+
targetType: "folder",
|
|
7743
|
+
requestedBy: userEmail,
|
|
7744
|
+
reason,
|
|
7745
|
+
baseUrl: ctx.baseUrl
|
|
7746
|
+
});
|
|
7747
|
+
return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or decline.
|
|
7748
|
+
Request id: \`${request2.id}\``;
|
|
7749
|
+
}
|
|
7750
|
+
const node = await resolveTargetNode(storage, args);
|
|
7751
|
+
if (typeof node === "string") return node;
|
|
7752
|
+
if (!await canReadNode(nestId, node.id, userId, userEmail)) {
|
|
7753
|
+
return `Node not found: ${node.id}`;
|
|
7754
|
+
}
|
|
7755
|
+
const request = await requestDeletion({
|
|
7756
|
+
nestId,
|
|
7757
|
+
nodeId: node.id,
|
|
7758
|
+
targetType: "document",
|
|
7759
|
+
requestedBy: userEmail,
|
|
7760
|
+
reason,
|
|
7761
|
+
baseUrl: ctx.baseUrl
|
|
7762
|
+
});
|
|
7763
|
+
return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or decline \u2014 nothing is removed yet.
|
|
7764
|
+
Request id: \`${request.id}\``;
|
|
7765
|
+
}
|
|
7766
|
+
case "context_deletion_queue": {
|
|
7767
|
+
const status = args.status === "declined" ? "declined" : "pending";
|
|
7768
|
+
const requests = await listDeletionRequests({ nestId, status });
|
|
7769
|
+
if (requests.length === 0) {
|
|
7770
|
+
return status === "pending" ? "No deletion requests waiting. All caught up!" : "No declined deletion requests.";
|
|
7771
|
+
}
|
|
7772
|
+
const list = requests.map((r, i) => {
|
|
7773
|
+
const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
|
|
7774
|
+
const verdict = r.status === "declined" ? `
|
|
7775
|
+
Declined by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
|
|
7776
|
+
return `${i + 1}. **${kind}${r.title || r.nodeId}**
|
|
7777
|
+
Asked by ${r.requestedBy}: "${r.reason}"
|
|
7778
|
+
Request id: \`${r.id}\`${verdict}`;
|
|
7779
|
+
}).join("\n\n");
|
|
7780
|
+
return `# Deletion requests (${status})
|
|
7781
|
+
**${requests.length} item(s)**
|
|
7782
|
+
|
|
7783
|
+
${list}`;
|
|
7784
|
+
}
|
|
7785
|
+
case "context_resolve_deletion": {
|
|
7786
|
+
const requestId = typeof args.request_id === "string" ? args.request_id : "";
|
|
7787
|
+
const action = args.action === "delete" ? "delete" : args.action === "decline" ? "decline" : null;
|
|
7788
|
+
if (!requestId || !action) {
|
|
7789
|
+
return 'request_id and action are required \u2014 action is "delete" or "decline". Use context_deletion_queue to list request ids.';
|
|
7790
|
+
}
|
|
7791
|
+
const request = await getDeletionRequest(requestId);
|
|
7792
|
+
if (!request || request.nestId !== nestId || request.status !== "pending") {
|
|
7793
|
+
return `No pending deletion request with id ${requestId}.`;
|
|
7794
|
+
}
|
|
7795
|
+
const roles = await resolveUserRoles(nestId, userEmail);
|
|
7796
|
+
if (!roles.includes("owner") && !roles.includes("admin")) {
|
|
7797
|
+
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.`;
|
|
7798
|
+
}
|
|
7799
|
+
const label = await deletionTargetTitle(request);
|
|
7800
|
+
if (action === "decline") {
|
|
7801
|
+
const note = typeof args.note === "string" ? args.note.trim() : "";
|
|
7802
|
+
if (!note) {
|
|
7803
|
+
return "note is required when declining \u2014 the requester is told why it stays.";
|
|
7804
|
+
}
|
|
7805
|
+
await declineDeletion({
|
|
7806
|
+
nestId,
|
|
7807
|
+
id: requestId,
|
|
7808
|
+
declinedBy: userEmail,
|
|
7809
|
+
note,
|
|
7810
|
+
baseUrl: ctx.baseUrl
|
|
7811
|
+
});
|
|
7812
|
+
return `Declined the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
|
|
7813
|
+
}
|
|
7814
|
+
if (request.targetType === "nest") {
|
|
7815
|
+
await deleteNest(nestId);
|
|
7816
|
+
} else if (request.targetType === "folder") {
|
|
7817
|
+
await deleteFolder(nestId, request.nodeId, userEmail);
|
|
7818
|
+
await dropDeletionRequest(request.id);
|
|
7819
|
+
} else {
|
|
7820
|
+
await deleteNode(nestId, request.nodeId, userEmail);
|
|
7821
|
+
}
|
|
7822
|
+
await notifyDeletionResolved({
|
|
7823
|
+
nestId,
|
|
7824
|
+
nodeId: request.nodeId,
|
|
7825
|
+
docTitle: label,
|
|
7826
|
+
outcome: "deleted",
|
|
7827
|
+
requestedBy: request.requestedBy,
|
|
7828
|
+
resolvedBy: userEmail,
|
|
7829
|
+
requestId: request.id,
|
|
7830
|
+
targetType: request.targetType,
|
|
7831
|
+
baseUrl: ctx.baseUrl
|
|
7832
|
+
});
|
|
7833
|
+
return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
|
|
7834
|
+
}
|
|
7340
7835
|
case "context_review_queue": {
|
|
7341
7836
|
const status = args.status || "pending";
|
|
7342
7837
|
const result = await getReviewQueue({
|
|
@@ -7355,6 +7850,30 @@ ${sections}`;
|
|
|
7355
7850
|
**${result.total} item(s)**
|
|
7356
7851
|
|
|
7357
7852
|
${list}`;
|
|
7853
|
+
}
|
|
7854
|
+
case "context_my_drafts": {
|
|
7855
|
+
const { drafts, total } = await listMyDrafts({
|
|
7856
|
+
authorEmail: userEmail,
|
|
7857
|
+
nestIds: [ctx.nestId],
|
|
7858
|
+
limit: 100
|
|
7859
|
+
});
|
|
7860
|
+
if (total === 0) {
|
|
7861
|
+
return "No unsubmitted drafts \u2014 everything you've written here is in review or approved.";
|
|
7862
|
+
}
|
|
7863
|
+
const list = drafts.map(
|
|
7864
|
+
(d, i) => `${i + 1}. **${d.title}** v${d.version} \u2014 ${d.status}
|
|
7865
|
+
Path: ${d.nodeId}
|
|
7866
|
+
Last edited: ${d.updatedAt}`
|
|
7867
|
+
).join("\n\n");
|
|
7868
|
+
const more = total > drafts.length ? `
|
|
7869
|
+
|
|
7870
|
+
_Showing the ${drafts.length} most recent of ${total}._` : "";
|
|
7871
|
+
return `# My Drafts
|
|
7872
|
+
**${total} unsubmitted document(s)**
|
|
7873
|
+
|
|
7874
|
+
${list}${more}
|
|
7875
|
+
|
|
7876
|
+
Submit one with \`context_submit_review\`.`;
|
|
7358
7877
|
}
|
|
7359
7878
|
case "context_submit_review": {
|
|
7360
7879
|
const docs = await storage.discoverDocuments();
|
|
@@ -7397,17 +7916,15 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
7397
7916
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7398
7917
|
);
|
|
7399
7918
|
if (!node) return `Node not found: ${args.title}`;
|
|
7400
|
-
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
7401
7919
|
try {
|
|
7402
7920
|
const request = await approve({
|
|
7403
7921
|
nestId: ctx.nestId,
|
|
7404
7922
|
nodeId: node.id,
|
|
7405
|
-
version: currentVersion,
|
|
7406
7923
|
approvedBy: ctx.userEmail,
|
|
7407
7924
|
note: args.note,
|
|
7408
7925
|
baseUrl: ctx.baseUrl
|
|
7409
7926
|
});
|
|
7410
|
-
return `Approved "${args.title}" v${
|
|
7927
|
+
return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
|
|
7411
7928
|
Note: ${args.note}` : ""}`;
|
|
7412
7929
|
} catch (err) {
|
|
7413
7930
|
return `Cannot approve: ${err.message}`;
|
|
@@ -7419,17 +7936,15 @@ Note: ${args.note}` : ""}`;
|
|
|
7419
7936
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7420
7937
|
);
|
|
7421
7938
|
if (!node) return `Node not found: ${args.title}`;
|
|
7422
|
-
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
7423
7939
|
try {
|
|
7424
7940
|
const request = await reject({
|
|
7425
7941
|
nestId: ctx.nestId,
|
|
7426
7942
|
nodeId: node.id,
|
|
7427
|
-
version: currentVersion,
|
|
7428
7943
|
rejectedBy: ctx.userEmail,
|
|
7429
7944
|
note: args.note,
|
|
7430
7945
|
baseUrl: ctx.baseUrl
|
|
7431
7946
|
});
|
|
7432
|
-
return `Rejected "${args.title}" v${
|
|
7947
|
+
return `Rejected "${args.title}" v${request.version}.
|
|
7433
7948
|
Reason: ${args.note}`;
|
|
7434
7949
|
} catch (err) {
|
|
7435
7950
|
return `Cannot reject: ${err.message}`;
|
|
@@ -7510,8 +8025,8 @@ ${list}`;
|
|
|
7510
8025
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
7511
8026
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
7512
8027
|
try {
|
|
7513
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
7514
|
-
const db = (await import("./client-
|
|
8028
|
+
const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
|
|
8029
|
+
const db = (await import("./client-NFUHPLLM.js")).getDb();
|
|
7515
8030
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
7516
8031
|
const e = normalizeEmail2(String(args.email || ""));
|
|
7517
8032
|
if (!e) return "email is required.";
|
|
@@ -8598,7 +9113,7 @@ definitionRoutes.delete("/:id", async (c) => {
|
|
|
8598
9113
|
import { Hono as Hono18 } from "hono";
|
|
8599
9114
|
import { v4 as uuid13 } from "uuid";
|
|
8600
9115
|
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
8601
|
-
import { join as
|
|
9116
|
+
import { join as join5 } from "path";
|
|
8602
9117
|
var assetRoutes = new Hono18();
|
|
8603
9118
|
var CONTENT_TYPES = {
|
|
8604
9119
|
png: "image/png",
|
|
@@ -8646,9 +9161,9 @@ assetRoutes.post("/", async (c) => {
|
|
|
8646
9161
|
);
|
|
8647
9162
|
}
|
|
8648
9163
|
const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
|
|
8649
|
-
const dir =
|
|
9164
|
+
const dir = join5(resolveNestPath(nestId), "assets");
|
|
8650
9165
|
await mkdir2(dir, { recursive: true });
|
|
8651
|
-
await writeFile(
|
|
9166
|
+
await writeFile(join5(dir, name), Buffer.from(await file.arrayBuffer()));
|
|
8652
9167
|
const url = `/nests/${nestId}/assets/${name}`;
|
|
8653
9168
|
return c.json(
|
|
8654
9169
|
{
|
|
@@ -8670,7 +9185,7 @@ assetRoutes.get("/:file", async (c) => {
|
|
|
8670
9185
|
}
|
|
8671
9186
|
let bytes;
|
|
8672
9187
|
try {
|
|
8673
|
-
bytes = await readFile2(
|
|
9188
|
+
bytes = await readFile2(join5(resolveNestPath(nestId), "assets", name));
|
|
8674
9189
|
} catch {
|
|
8675
9190
|
throw new NotFoundError("Asset not found");
|
|
8676
9191
|
}
|
|
@@ -8690,6 +9205,7 @@ import { Hono as Hono19 } from "hono";
|
|
|
8690
9205
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8691
9206
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
8692
9207
|
import { z as z2 } from "zod";
|
|
9208
|
+
var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
|
|
8693
9209
|
async function getUserEmail3(userId) {
|
|
8694
9210
|
const db = getDb();
|
|
8695
9211
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
@@ -8871,6 +9387,38 @@ var SERVER_TOOL_DEFINITIONS = [
|
|
|
8871
9387
|
},
|
|
8872
9388
|
required: ["nest", "selector"]
|
|
8873
9389
|
}
|
|
9390
|
+
},
|
|
9391
|
+
{
|
|
9392
|
+
name: "nest_create",
|
|
9393
|
+
description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
|
|
9394
|
+
inputSchema: {
|
|
9395
|
+
type: "object",
|
|
9396
|
+
properties: {
|
|
9397
|
+
name: { type: "string", description: "Nest name" },
|
|
9398
|
+
description: { type: "string", description: "Optional description" }
|
|
9399
|
+
},
|
|
9400
|
+
required: ["name"]
|
|
9401
|
+
}
|
|
9402
|
+
},
|
|
9403
|
+
{
|
|
9404
|
+
name: "context_create",
|
|
9405
|
+
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.",
|
|
9406
|
+
inputSchema: {
|
|
9407
|
+
type: "object",
|
|
9408
|
+
properties: {
|
|
9409
|
+
...NEST_ARG,
|
|
9410
|
+
title: { type: "string", description: "Descriptive title" },
|
|
9411
|
+
content: { type: "string", description: "Markdown content body" },
|
|
9412
|
+
type: { type: "string", description: "Node type (default: document)" },
|
|
9413
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags" },
|
|
9414
|
+
scope: { type: "string", description: "Visibility scope" },
|
|
9415
|
+
folder: {
|
|
9416
|
+
type: "string",
|
|
9417
|
+
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
9418
|
+
}
|
|
9419
|
+
},
|
|
9420
|
+
required: ["nest", "title", "content"]
|
|
9421
|
+
}
|
|
8874
9422
|
}
|
|
8875
9423
|
];
|
|
8876
9424
|
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
@@ -8879,6 +9427,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
|
|
|
8879
9427
|
if (!nests.length) return "No nests accessible with these credentials.";
|
|
8880
9428
|
return renderNestIndexMarkdown(nests, baseUrl);
|
|
8881
9429
|
}
|
|
9430
|
+
if (SERVER_WRITE_TOOLS.has(toolName)) {
|
|
9431
|
+
if (isSuspended()) {
|
|
9432
|
+
const reason = getSuspensionReason();
|
|
9433
|
+
return `This server is suspended by PromptOwl; writes are blocked${reason ? ` \u2014 ${reason}` : ""}. Contact support@promptowl.ai.`;
|
|
9434
|
+
}
|
|
9435
|
+
if (!getCurrentLicense()?.valid) {
|
|
9436
|
+
return "This server has no valid PromptOwl license installed, so writes are disabled. Ask the server admin to install one (POST /license/install).";
|
|
9437
|
+
}
|
|
9438
|
+
}
|
|
9439
|
+
if (toolName === "nest_create") {
|
|
9440
|
+
if (nestScope) {
|
|
9441
|
+
return "This credential is scoped to a single nest and cannot create new ones.";
|
|
9442
|
+
}
|
|
9443
|
+
try {
|
|
9444
|
+
const nest2 = await createNest(
|
|
9445
|
+
userId,
|
|
9446
|
+
String(args.name ?? ""),
|
|
9447
|
+
args.description ? String(args.description) : void 0
|
|
9448
|
+
);
|
|
9449
|
+
return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
|
|
9450
|
+
} catch (err) {
|
|
9451
|
+
if (err instanceof ValidationError) return err.message;
|
|
9452
|
+
throw err;
|
|
9453
|
+
}
|
|
9454
|
+
}
|
|
8882
9455
|
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
8883
9456
|
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
8884
9457
|
if (!nest) return inaccessible;
|
|
@@ -9037,7 +9610,9 @@ Streamable HTTP, stateless. Two endpoints:
|
|
|
9037
9610
|
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
9038
9611
|
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
9039
9612
|
each takes a \`nest\` argument (id or exact name) plus the same
|
|
9040
|
-
selector/hops/max_tokens parameters as the REST API.
|
|
9613
|
+
selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
|
|
9614
|
+
\`nest_create\` (new nest owned by the caller) and \`context_create\`
|
|
9615
|
+
(new document in a writable nest; a \`folder\` path creates the folder).
|
|
9041
9616
|
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
9042
9617
|
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
9043
9618
|
|
|
@@ -9425,8 +10000,8 @@ function rowToComment(row) {
|
|
|
9425
10000
|
}
|
|
9426
10001
|
|
|
9427
10002
|
// src/governance/stewards-parser.ts
|
|
9428
|
-
import { readFileSync as
|
|
9429
|
-
import { join as
|
|
10003
|
+
import { readFileSync as readFileSync3, existsSync } from "fs";
|
|
10004
|
+
import { join as join6 } from "path";
|
|
9430
10005
|
function parseStewardsYaml(content) {
|
|
9431
10006
|
const result = { version: 1 };
|
|
9432
10007
|
const lines = content.split("\n");
|
|
@@ -9492,13 +10067,13 @@ function parseEntry(str) {
|
|
|
9492
10067
|
function loadStewardsConfig(nestId) {
|
|
9493
10068
|
const nestPath = resolveNestPath(nestId);
|
|
9494
10069
|
const candidates = [
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
10070
|
+
join6(nestPath, "stewards.yaml"),
|
|
10071
|
+
join6(nestPath, "stewards.yml"),
|
|
10072
|
+
join6(nestPath, ".context", "stewards.yaml")
|
|
9498
10073
|
];
|
|
9499
10074
|
for (const candidatePath of candidates) {
|
|
9500
10075
|
if (existsSync(candidatePath)) {
|
|
9501
|
-
const content =
|
|
10076
|
+
const content = readFileSync3(candidatePath, "utf-8");
|
|
9502
10077
|
return parseStewardsYaml(content);
|
|
9503
10078
|
}
|
|
9504
10079
|
}
|
|
@@ -9554,7 +10129,8 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
9554
10129
|
documentId: body.documentId,
|
|
9555
10130
|
tagName: body.tagName,
|
|
9556
10131
|
users: body.users,
|
|
9557
|
-
assignedBy
|
|
10132
|
+
assignedBy,
|
|
10133
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
9558
10134
|
});
|
|
9559
10135
|
return c.json({ stewards: created2 }, 201);
|
|
9560
10136
|
}
|
|
@@ -9572,7 +10148,8 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
9572
10148
|
role: body.role
|
|
9573
10149
|
}
|
|
9574
10150
|
],
|
|
9575
|
-
assignedBy
|
|
10151
|
+
assignedBy,
|
|
10152
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
9576
10153
|
});
|
|
9577
10154
|
return c.json({ steward: created[0] }, 201);
|
|
9578
10155
|
});
|
|
@@ -9628,6 +10205,123 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
9628
10205
|
}
|
|
9629
10206
|
return c.json({ ...result, requests });
|
|
9630
10207
|
});
|
|
10208
|
+
function denyUnlessNestManager(c, what) {
|
|
10209
|
+
const permission = c.get("nestPermission");
|
|
10210
|
+
if (permission === "owner" || permission === "admin") return null;
|
|
10211
|
+
return c.json(
|
|
10212
|
+
{
|
|
10213
|
+
error: `Only the nest owner or an admin can delete ${what}. Request its deletion instead, or ask an admin to action it.`
|
|
10214
|
+
},
|
|
10215
|
+
403
|
|
10216
|
+
);
|
|
10217
|
+
}
|
|
10218
|
+
governanceRoutes.post("/delete-folder", async (c) => {
|
|
10219
|
+
const nestId = c.req.param("nestId");
|
|
10220
|
+
const denied = denyUnlessNestManager(c, "a folder");
|
|
10221
|
+
if (denied) return denied;
|
|
10222
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10223
|
+
if (!body.folder?.trim()) {
|
|
10224
|
+
throw new ValidationError("folder is required");
|
|
10225
|
+
}
|
|
10226
|
+
const deleted = await deleteFolder(
|
|
10227
|
+
nestId,
|
|
10228
|
+
body.folder.trim(),
|
|
10229
|
+
await getUserEmail4(c)
|
|
10230
|
+
);
|
|
10231
|
+
return c.json({ deleted: deleted.length, nodeIds: deleted });
|
|
10232
|
+
});
|
|
10233
|
+
governanceRoutes.post("/request-deletion", async (c) => {
|
|
10234
|
+
const nestId = c.req.param("nestId");
|
|
10235
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10236
|
+
const reason = body.reason?.trim();
|
|
10237
|
+
if (!reason) {
|
|
10238
|
+
throw new ValidationError("A reason for the deletion request is required");
|
|
10239
|
+
}
|
|
10240
|
+
if (body.targetType !== "folder" && body.targetType !== "nest") {
|
|
10241
|
+
throw new ValidationError(
|
|
10242
|
+
'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
|
|
10243
|
+
);
|
|
10244
|
+
}
|
|
10245
|
+
const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
|
|
10246
|
+
if (body.targetType === "folder" && !target) {
|
|
10247
|
+
throw new ValidationError("target (the folder path) is required");
|
|
10248
|
+
}
|
|
10249
|
+
if (body.targetType === "folder") {
|
|
10250
|
+
const { storage } = await engineCache.get(nestId);
|
|
10251
|
+
const docs = await storage.discoverDocuments();
|
|
10252
|
+
if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
|
|
10253
|
+
return c.json({ error: `Folder not found: ${target}` }, 404);
|
|
10254
|
+
}
|
|
10255
|
+
}
|
|
10256
|
+
const request = await requestDeletion({
|
|
10257
|
+
nestId,
|
|
10258
|
+
nodeId: target,
|
|
10259
|
+
targetType: body.targetType,
|
|
10260
|
+
requestedBy: await getUserEmail4(c),
|
|
10261
|
+
reason,
|
|
10262
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10263
|
+
});
|
|
10264
|
+
return c.json({ request }, 201);
|
|
10265
|
+
});
|
|
10266
|
+
governanceRoutes.get("/deletion-requests", async (c) => {
|
|
10267
|
+
const nestId = c.req.param("nestId");
|
|
10268
|
+
const raw = c.req.query("status");
|
|
10269
|
+
const status = raw === "declined" || raw === "all" ? raw : "pending";
|
|
10270
|
+
const requests = await listDeletionRequests({ nestId, status });
|
|
10271
|
+
return c.json({ requests, total: requests.length });
|
|
10272
|
+
});
|
|
10273
|
+
governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
|
|
10274
|
+
const nestId = c.req.param("nestId");
|
|
10275
|
+
const request = await getDeletionRequest(c.req.param("id"));
|
|
10276
|
+
if (!request || request.nestId !== nestId || request.status !== "pending") {
|
|
10277
|
+
return c.json({ error: "Deletion request not found" }, 404);
|
|
10278
|
+
}
|
|
10279
|
+
const actor = await getUserEmail4(c);
|
|
10280
|
+
const docTitle = await deletionTargetTitle(request);
|
|
10281
|
+
if (request.targetType === "nest" || request.targetType === "folder") {
|
|
10282
|
+
const denied = denyUnlessNestManager(
|
|
10283
|
+
c,
|
|
10284
|
+
request.targetType === "nest" ? "a nest" : "a folder"
|
|
10285
|
+
);
|
|
10286
|
+
if (denied) return denied;
|
|
10287
|
+
}
|
|
10288
|
+
if (request.targetType === "nest") {
|
|
10289
|
+
await deleteNest(nestId);
|
|
10290
|
+
} else if (request.targetType === "folder") {
|
|
10291
|
+
await deleteFolder(nestId, request.nodeId, actor);
|
|
10292
|
+
await dropDeletionRequest(request.id);
|
|
10293
|
+
} else {
|
|
10294
|
+
await deleteNode(nestId, request.nodeId, actor);
|
|
10295
|
+
}
|
|
10296
|
+
await notifyDeletionResolved({
|
|
10297
|
+
nestId,
|
|
10298
|
+
nodeId: request.nodeId,
|
|
10299
|
+
docTitle,
|
|
10300
|
+
outcome: "deleted",
|
|
10301
|
+
requestedBy: request.requestedBy,
|
|
10302
|
+
resolvedBy: actor,
|
|
10303
|
+
requestId: request.id,
|
|
10304
|
+
targetType: request.targetType,
|
|
10305
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10306
|
+
});
|
|
10307
|
+
return c.json({ deleted: true, nodeId: request.nodeId });
|
|
10308
|
+
});
|
|
10309
|
+
governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
|
|
10310
|
+
const nestId = c.req.param("nestId");
|
|
10311
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10312
|
+
if (!body.note?.trim()) {
|
|
10313
|
+
throw new ValidationError("A note is required when declining");
|
|
10314
|
+
}
|
|
10315
|
+
const request = await declineDeletion({
|
|
10316
|
+
nestId,
|
|
10317
|
+
id: c.req.param("id"),
|
|
10318
|
+
declinedBy: await getUserEmail4(c),
|
|
10319
|
+
note: body.note.trim(),
|
|
10320
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10321
|
+
});
|
|
10322
|
+
if (!request) return c.json({ error: "Deletion request not found" }, 404);
|
|
10323
|
+
return c.json({ request });
|
|
10324
|
+
});
|
|
9631
10325
|
governanceRoutes.get("/feed", async (c) => {
|
|
9632
10326
|
const nestId = c.req.param("nestId");
|
|
9633
10327
|
const filterRaw = c.req.query("filter");
|
|
@@ -9689,25 +10383,71 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
9689
10383
|
governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
9690
10384
|
const nestId = c.req.param("nestId");
|
|
9691
10385
|
const nodeId = c.req.param("nodeId");
|
|
10386
|
+
const userId = c.get("userId");
|
|
10387
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
10388
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
10389
|
+
return c.json(
|
|
10390
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
10391
|
+
403
|
|
10392
|
+
);
|
|
10393
|
+
}
|
|
9692
10394
|
const allVersions = await getVersions(nestId, nodeId);
|
|
9693
10395
|
const approved = await getApprovedVersion(nestId, nodeId);
|
|
9694
10396
|
const { versions: versionManager } = await engineCache.get(nestId);
|
|
9695
|
-
const
|
|
10397
|
+
const resolutions = await getDb().all(
|
|
10398
|
+
`SELECT version, status, resolved_by
|
|
10399
|
+
FROM review_requests
|
|
10400
|
+
WHERE nest_id = ? AND node_id = ?
|
|
10401
|
+
AND status IN ('approved', 'rejected')
|
|
10402
|
+
AND resolved_by IS NOT NULL
|
|
10403
|
+
ORDER BY resolved_at DESC`,
|
|
10404
|
+
[nestId, nodeId]
|
|
10405
|
+
);
|
|
10406
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
10407
|
+
for (const r of resolutions) {
|
|
10408
|
+
if (!byVersion.has(r.version)) {
|
|
10409
|
+
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
10410
|
+
}
|
|
10411
|
+
}
|
|
10412
|
+
const enriched = await Promise.all(
|
|
9696
10413
|
allVersions.map(async (v) => {
|
|
10414
|
+
const r = byVersion.get(v.version);
|
|
10415
|
+
const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
|
|
9697
10416
|
try {
|
|
9698
|
-
const
|
|
9699
|
-
return { ...v,
|
|
10417
|
+
const diff = await versionManager.getDiff(nodeId, v.version);
|
|
10418
|
+
return { ...v, ...resolution, diff: diff ?? void 0 };
|
|
9700
10419
|
} catch {
|
|
9701
|
-
return v;
|
|
10420
|
+
return { ...v, ...resolution };
|
|
9702
10421
|
}
|
|
9703
10422
|
})
|
|
9704
10423
|
);
|
|
9705
10424
|
return c.json({
|
|
9706
|
-
versions:
|
|
10425
|
+
versions: enriched,
|
|
9707
10426
|
approvedVersion: approved,
|
|
9708
10427
|
currentVersion: allVersions[0]?.version || 0
|
|
9709
10428
|
});
|
|
9710
10429
|
});
|
|
10430
|
+
governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
|
|
10431
|
+
const nestId = c.req.param("nestId");
|
|
10432
|
+
const nodeId = c.req.param("nodeId");
|
|
10433
|
+
const version = Number(c.req.param("version"));
|
|
10434
|
+
const userId = c.get("userId");
|
|
10435
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
10436
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
10437
|
+
return c.json(
|
|
10438
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
10439
|
+
403
|
|
10440
|
+
);
|
|
10441
|
+
}
|
|
10442
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
10443
|
+
try {
|
|
10444
|
+
const raw = await versionManager.reconstructVersion(nodeId, version);
|
|
10445
|
+
return c.json({ version, content: bodyOnly(nodeId, raw) });
|
|
10446
|
+
} catch (err) {
|
|
10447
|
+
console.error("reconstructVersion failed", nestId, nodeId, version, err);
|
|
10448
|
+
return c.json({ version, content: null }, 200);
|
|
10449
|
+
}
|
|
10450
|
+
});
|
|
9711
10451
|
governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
9712
10452
|
const nestId = c.req.param("nestId");
|
|
9713
10453
|
const nodeId = c.req.param("nodeId");
|
|
@@ -9820,6 +10560,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
9820
10560
|
201
|
|
9821
10561
|
);
|
|
9822
10562
|
});
|
|
10563
|
+
governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
|
|
10564
|
+
const nestId = c.req.param("nestId");
|
|
10565
|
+
const nodeId = c.req.param("nodeId");
|
|
10566
|
+
const body = await c.req.json().catch(() => ({}));
|
|
10567
|
+
const reason = body.reason?.trim();
|
|
10568
|
+
if (!reason) {
|
|
10569
|
+
throw new ValidationError("A reason for the deletion request is required");
|
|
10570
|
+
}
|
|
10571
|
+
const userEmail = await getUserEmail4(c);
|
|
10572
|
+
if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
|
|
10573
|
+
return c.json({ error: `Node not found: ${nodeId}` }, 404);
|
|
10574
|
+
}
|
|
10575
|
+
const { storage } = await engineCache.get(nestId);
|
|
10576
|
+
try {
|
|
10577
|
+
await storage.readDocument(nodeId);
|
|
10578
|
+
} catch {
|
|
10579
|
+
return c.json({ error: `Node not found: ${nodeId}` }, 404);
|
|
10580
|
+
}
|
|
10581
|
+
const request = await requestDeletion({
|
|
10582
|
+
nestId,
|
|
10583
|
+
nodeId,
|
|
10584
|
+
requestedBy: userEmail,
|
|
10585
|
+
reason,
|
|
10586
|
+
baseUrl: requestBaseUrl(c.req.url)
|
|
10587
|
+
});
|
|
10588
|
+
return c.json({ request }, 201);
|
|
10589
|
+
});
|
|
9823
10590
|
governanceNodeRoutes.post(
|
|
9824
10591
|
"/:nodeId{.+}/external-edits/:suggestionId/approve",
|
|
9825
10592
|
async (c) => {
|
|
@@ -9891,7 +10658,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
9891
10658
|
const request = await approve({
|
|
9892
10659
|
nestId,
|
|
9893
10660
|
nodeId,
|
|
9894
|
-
version: await getCurrentVersion(nestId, nodeId),
|
|
9895
10661
|
approvedBy: userEmail,
|
|
9896
10662
|
note: body.note,
|
|
9897
10663
|
override: body.override && isAdmin,
|
|
@@ -9914,7 +10680,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
9914
10680
|
const request = await reject({
|
|
9915
10681
|
nestId,
|
|
9916
10682
|
nodeId,
|
|
9917
|
-
version: await getCurrentVersion(nestId, nodeId),
|
|
9918
10683
|
rejectedBy: userEmail,
|
|
9919
10684
|
note: body.note,
|
|
9920
10685
|
baseUrl: requestBaseUrl(c.req.url)
|
|
@@ -10007,19 +10772,19 @@ async function ensureAnonymousUser() {
|
|
|
10007
10772
|
// src/app.ts
|
|
10008
10773
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
10009
10774
|
import { fileURLToPath } from "url";
|
|
10010
|
-
import { dirname as dirname2, join as
|
|
10011
|
-
import { existsSync as existsSync2, readFileSync as
|
|
10775
|
+
import { dirname as dirname2, join as join7, relative as relative2 } from "path";
|
|
10776
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
|
|
10012
10777
|
var HERE = dirname2(fileURLToPath(import.meta.url));
|
|
10013
10778
|
var SERVICE_VERSION = (() => {
|
|
10014
10779
|
try {
|
|
10015
|
-
return JSON.parse(
|
|
10780
|
+
return JSON.parse(readFileSync4(join7(HERE, "..", "package.json"), "utf8")).version;
|
|
10016
10781
|
} catch {
|
|
10017
10782
|
return "unknown";
|
|
10018
10783
|
}
|
|
10019
10784
|
})();
|
|
10020
10785
|
var UI_DIR_CANDIDATES = [
|
|
10021
|
-
|
|
10022
|
-
|
|
10786
|
+
join7(HERE, "web3"),
|
|
10787
|
+
join7(process.cwd(), "dist", "web3")
|
|
10023
10788
|
];
|
|
10024
10789
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
10025
10790
|
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
@@ -10055,6 +10820,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
10055
10820
|
}
|
|
10056
10821
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
10057
10822
|
});
|
|
10823
|
+
function nestPathParts(path) {
|
|
10824
|
+
return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
|
|
10825
|
+
}
|
|
10826
|
+
var flexAuthWithPromptowlTicket = createMiddleware2(
|
|
10827
|
+
async (c, next) => {
|
|
10828
|
+
const header = c.req.header("Authorization") || "";
|
|
10829
|
+
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
|
|
10830
|
+
const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
|
|
10831
|
+
const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
|
|
10832
|
+
if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
|
|
10833
|
+
let claims;
|
|
10834
|
+
try {
|
|
10835
|
+
claims = verifySsoTicket(token, secret);
|
|
10836
|
+
} catch {
|
|
10837
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
10838
|
+
}
|
|
10839
|
+
if (claims.scope !== "mcp" || claims.jti) {
|
|
10840
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
10841
|
+
}
|
|
10842
|
+
const expectedAud = config.PUBLIC_BASE_URL;
|
|
10843
|
+
if (expectedAud && (claims.aud || "").replace(/\/$/, "") !== expectedAud) {
|
|
10844
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
10845
|
+
}
|
|
10846
|
+
const sub = claims.sub.trim();
|
|
10847
|
+
if (!isValidTicketSub(sub)) {
|
|
10848
|
+
return c.json({ error: "Invalid service ticket" }, 401);
|
|
10849
|
+
}
|
|
10850
|
+
const db = getDb();
|
|
10851
|
+
const user = await db.get(
|
|
10852
|
+
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
10853
|
+
[sub]
|
|
10854
|
+
);
|
|
10855
|
+
if (!user) {
|
|
10856
|
+
return c.json(
|
|
10857
|
+
{ error: "No ContextNest account exists for this PromptOwl user" },
|
|
10858
|
+
404
|
|
10859
|
+
);
|
|
10860
|
+
}
|
|
10861
|
+
c.set("userId", user.id);
|
|
10862
|
+
c.set("nestScope", null);
|
|
10863
|
+
return next();
|
|
10864
|
+
}
|
|
10865
|
+
);
|
|
10058
10866
|
var APPROVE_CACHE_TTL_MS = 3e4;
|
|
10059
10867
|
var approveCache = /* @__PURE__ */ new Map();
|
|
10060
10868
|
async function canApproveInNest(nestId, email) {
|
|
@@ -10240,6 +11048,7 @@ function createApp() {
|
|
|
10240
11048
|
});
|
|
10241
11049
|
const serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
|
|
10242
11050
|
app.use("/admin/settings", flexAuthMiddleware);
|
|
11051
|
+
app.use("/admin/settings/*", flexAuthMiddleware);
|
|
10243
11052
|
const currentServerSettings = () => ({
|
|
10244
11053
|
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
10245
11054
|
manual_sign_in: config.MANUAL_SIGN_IN,
|
|
@@ -10272,6 +11081,7 @@ function createApp() {
|
|
|
10272
11081
|
// Server-wide runner default. NEVER the value — masked tail only.
|
|
10273
11082
|
anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
|
|
10274
11083
|
slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
|
|
11084
|
+
msteams_webhook_url: config.MSTEAMS_WEBHOOK_URL ?? "",
|
|
10275
11085
|
smtp_url: config.SMTP_URL ?? "",
|
|
10276
11086
|
notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
|
|
10277
11087
|
notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
|
|
@@ -10281,6 +11091,20 @@ function createApp() {
|
|
|
10281
11091
|
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
10282
11092
|
return c.json(currentServerSettings());
|
|
10283
11093
|
});
|
|
11094
|
+
app.post("/admin/settings/test-notification", async (c) => {
|
|
11095
|
+
if (!await serverAdminAllowed(c))
|
|
11096
|
+
return c.json({ error: "Only the server admin can do this." }, 403);
|
|
11097
|
+
let body;
|
|
11098
|
+
try {
|
|
11099
|
+
body = await c.req.json();
|
|
11100
|
+
} catch {
|
|
11101
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
11102
|
+
}
|
|
11103
|
+
if (body.channel !== "slack" && body.channel !== "teams")
|
|
11104
|
+
return c.json({ error: "channel must be 'slack' or 'teams'" }, 400);
|
|
11105
|
+
const result = await sendTestNotification(body.channel);
|
|
11106
|
+
return result.ok ? c.json({ ok: true }) : c.json({ error: result.error }, 502);
|
|
11107
|
+
});
|
|
10284
11108
|
app.patch("/admin/settings", async (c) => {
|
|
10285
11109
|
if (!await serverAdminAllowed(c))
|
|
10286
11110
|
return c.json({ error: "Only the server admin can change this." }, 403);
|
|
@@ -10473,6 +11297,12 @@ function createApp() {
|
|
|
10473
11297
|
addError("slack_webhook_url", "Slack webhook must be an https:// URL (or leave it empty to turn Slack notifications off).");
|
|
10474
11298
|
else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
|
|
10475
11299
|
}
|
|
11300
|
+
if ("msteams_webhook_url" in body) {
|
|
11301
|
+
const v = String(body.msteams_webhook_url ?? "").trim();
|
|
11302
|
+
if (v && !/^https:\/\//i.test(v))
|
|
11303
|
+
addError("msteams_webhook_url", "Microsoft Teams webhook must be an https:// URL (or leave it empty to turn Teams notifications off).");
|
|
11304
|
+
else pending.push({ name: "MSTEAMS_WEBHOOK_URL", value: v || null });
|
|
11305
|
+
}
|
|
10476
11306
|
if ("smtp_url" in body) {
|
|
10477
11307
|
const v = String(body.smtp_url ?? "").trim();
|
|
10478
11308
|
if (v && !/^smtps?:\/\//i.test(v))
|
|
@@ -10708,6 +11538,25 @@ function createApp() {
|
|
|
10708
11538
|
requested_at: r.requestedAt,
|
|
10709
11539
|
priority: r.priority
|
|
10710
11540
|
});
|
|
11541
|
+
const deletions = await listDeletionRequests({
|
|
11542
|
+
nestIds: [...nestName2.keys()],
|
|
11543
|
+
status: "pending"
|
|
11544
|
+
});
|
|
11545
|
+
const toDeletionItem = (r) => ({
|
|
11546
|
+
type: "deletion",
|
|
11547
|
+
nest_id: r.nestId,
|
|
11548
|
+
nest_name: nestName2.get(r.nestId),
|
|
11549
|
+
node_id: r.nodeId,
|
|
11550
|
+
title: r.title || r.nodeId,
|
|
11551
|
+
requested_by: r.requestedBy,
|
|
11552
|
+
requested_at: r.createdAt,
|
|
11553
|
+
priority: "normal",
|
|
11554
|
+
reason: r.reason,
|
|
11555
|
+
request_id: r.id,
|
|
11556
|
+
// node_id holds a folder path / nest id for those targets, so a client
|
|
11557
|
+
// must not treat it as a document to open.
|
|
11558
|
+
target_type: r.targetType
|
|
11559
|
+
});
|
|
10711
11560
|
const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
|
|
10712
11561
|
const others = requests.filter((r) => r.requestedBy !== me);
|
|
10713
11562
|
const nestApprove = new Map(
|
|
@@ -10720,8 +11569,27 @@ function createApp() {
|
|
|
10720
11569
|
)
|
|
10721
11570
|
)
|
|
10722
11571
|
);
|
|
10723
|
-
const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem)
|
|
10724
|
-
const waiting = requests.filter((r) => r.requestedBy === me).map(toItem)
|
|
11572
|
+
const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem);
|
|
11573
|
+
const waiting = requests.filter((r) => r.requestedBy === me).map(toItem);
|
|
11574
|
+
const othersDeletions = deletions.filter((r) => r.requestedBy !== me);
|
|
11575
|
+
const nestManage = new Map(
|
|
11576
|
+
await Promise.all(
|
|
11577
|
+
[...new Set(othersDeletions.map((r) => r.nestId))].map(
|
|
11578
|
+
async (nestId) => {
|
|
11579
|
+
const roles = await resolveUserRoles(nestId, me);
|
|
11580
|
+
return [nestId, roles.includes("owner") || roles.includes("admin")];
|
|
11581
|
+
}
|
|
11582
|
+
)
|
|
11583
|
+
)
|
|
11584
|
+
);
|
|
11585
|
+
items.push(
|
|
11586
|
+
...othersDeletions.filter((r) => nestManage.get(r.nestId)).map(toDeletionItem)
|
|
11587
|
+
);
|
|
11588
|
+
items.sort(byNewest);
|
|
11589
|
+
waiting.push(
|
|
11590
|
+
...deletions.filter((r) => r.requestedBy === me).map(toDeletionItem)
|
|
11591
|
+
);
|
|
11592
|
+
waiting.sort(byNewest);
|
|
10725
11593
|
const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
|
|
10726
11594
|
return c.json({
|
|
10727
11595
|
items,
|
|
@@ -10732,6 +11600,37 @@ function createApp() {
|
|
|
10732
11600
|
pagination: { total, limit, offset, has_more: offset + requests.length < total }
|
|
10733
11601
|
});
|
|
10734
11602
|
});
|
|
11603
|
+
app.get("/me/drafts", async (c) => {
|
|
11604
|
+
const userId = c.get("userId");
|
|
11605
|
+
const me = await resolveCallerEmail(userId);
|
|
11606
|
+
const nestName2 = /* @__PURE__ */ new Map();
|
|
11607
|
+
for (const n of await listVisibleNests(userId))
|
|
11608
|
+
nestName2.set(n.id, n.name ?? n.id);
|
|
11609
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
|
|
11610
|
+
const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
|
|
11611
|
+
const { drafts, total } = await listMyDrafts({
|
|
11612
|
+
authorEmail: me,
|
|
11613
|
+
nestIds: [...nestName2.keys()],
|
|
11614
|
+
limit,
|
|
11615
|
+
offset
|
|
11616
|
+
});
|
|
11617
|
+
return c.json({
|
|
11618
|
+
drafts: drafts.map((d) => ({
|
|
11619
|
+
nest_id: d.nestId,
|
|
11620
|
+
nest_name: nestName2.get(d.nestId),
|
|
11621
|
+
node_id: d.nodeId,
|
|
11622
|
+
title: d.title,
|
|
11623
|
+
// Where it actually lives — the thing that was impossible to find.
|
|
11624
|
+
folder: folderForNode(d.nodeId),
|
|
11625
|
+
status: d.status,
|
|
11626
|
+
version: d.version,
|
|
11627
|
+
updated_at: d.updatedAt
|
|
11628
|
+
})),
|
|
11629
|
+
// has_more off the REQUESTED page size, not the returned row count: the
|
|
11630
|
+
// existence filter can shorten a page, and that must not read as "last".
|
|
11631
|
+
pagination: { total, limit, offset, has_more: offset + limit < total }
|
|
11632
|
+
});
|
|
11633
|
+
});
|
|
10735
11634
|
app.get("/me/notifications", async (c) => {
|
|
10736
11635
|
const me = await resolveCallerEmail(c.get("userId"));
|
|
10737
11636
|
const unreadOnly = c.req.query("unread") === "true";
|
|
@@ -10771,15 +11670,18 @@ function createApp() {
|
|
|
10771
11670
|
app.route("/runs", runDetailRoutes);
|
|
10772
11671
|
app.route("/hooks", hookFireRoutes);
|
|
10773
11672
|
app.route("/llms.txt", llmsTxtRoutes);
|
|
10774
|
-
app.use("/index",
|
|
11673
|
+
app.use("/index", flexAuthWithPromptowlTicket);
|
|
10775
11674
|
app.route("/index", nestIndexRoutes);
|
|
10776
|
-
app.use("/mcp",
|
|
11675
|
+
app.use("/mcp", flexAuthWithPromptowlTicket);
|
|
10777
11676
|
app.route("/mcp", serverMcpRoutes);
|
|
10778
11677
|
const nestsApp = new Hono21();
|
|
10779
|
-
nestsApp.use("*", flexAuthMiddleware);
|
|
10780
11678
|
nestsApp.use("*", async (c, next) => {
|
|
10781
|
-
const
|
|
10782
|
-
const
|
|
11679
|
+
const parts = nestPathParts(c.req.path);
|
|
11680
|
+
const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
|
|
11681
|
+
return ticketEligible ? flexAuthWithPromptowlTicket(c, next) : flexAuthMiddleware(c, next);
|
|
11682
|
+
});
|
|
11683
|
+
nestsApp.use("*", async (c, next) => {
|
|
11684
|
+
const parts = nestPathParts(c.req.path);
|
|
10783
11685
|
if (parts.length < 2) return next();
|
|
10784
11686
|
if (parts[0] === "unsynced") return next();
|
|
10785
11687
|
const nestId = parts[0];
|
|
@@ -10816,7 +11718,8 @@ function createApp() {
|
|
|
10816
11718
|
const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
|
|
10817
11719
|
const isActivityPath = /\/activity$/.test(path2);
|
|
10818
11720
|
const isFeedPath = parts.length === 2 && parts[1] === "feed";
|
|
10819
|
-
const
|
|
11721
|
+
const isDeletionQueuePath = parts.length === 2 && parts[1] === "deletion-requests";
|
|
11722
|
+
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;
|
|
10820
11723
|
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
10821
11724
|
if (needsLicense) {
|
|
10822
11725
|
const lic = getCurrentLicense();
|
|
@@ -10862,6 +11765,7 @@ function createApp() {
|
|
|
10862
11765
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
10863
11766
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
10864
11767
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
11768
|
+
const isDeletionRequestAction = /\/request-deletion$/.test(path);
|
|
10865
11769
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
10866
11770
|
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
10867
11771
|
return c.json(
|
|
@@ -10880,7 +11784,7 @@ function createApp() {
|
|
|
10880
11784
|
required = c.req.method === "GET" ? "write" : "admin";
|
|
10881
11785
|
} else if (parts[1] === "hooks") {
|
|
10882
11786
|
required = "write";
|
|
10883
|
-
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
11787
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
10884
11788
|
required = "write";
|
|
10885
11789
|
}
|
|
10886
11790
|
const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
|
|
@@ -11160,6 +12064,111 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
11160
12064
|
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
11161
12065
|
);
|
|
11162
12066
|
}
|
|
12067
|
+
var NOTIFICATION_TITLES_MIGRATION_ID = "006_backfill_notification_titles";
|
|
12068
|
+
async function backfillNotificationTitles(db) {
|
|
12069
|
+
const already = await db.get(
|
|
12070
|
+
"SELECT id FROM schema_migrations WHERE id = ?",
|
|
12071
|
+
[NOTIFICATION_TITLES_MIGRATION_ID]
|
|
12072
|
+
);
|
|
12073
|
+
if (already) return;
|
|
12074
|
+
const rows = await db.all(
|
|
12075
|
+
"SELECT id, nest_id, message FROM notifications WHERE message LIKE '%nodes/%'"
|
|
12076
|
+
);
|
|
12077
|
+
let rewritten = 0;
|
|
12078
|
+
if (rows.length > 0) {
|
|
12079
|
+
const titlesByNest = /* @__PURE__ */ new Map();
|
|
12080
|
+
for (const nestId of new Set(rows.map((r) => r.nest_id))) {
|
|
12081
|
+
titlesByNest.set(nestId, await buildTitleMap(nestId));
|
|
12082
|
+
}
|
|
12083
|
+
for (const row of rows) {
|
|
12084
|
+
const titles = titlesByNest.get(row.nest_id) ?? /* @__PURE__ */ new Map();
|
|
12085
|
+
const next = row.message.replace(
|
|
12086
|
+
/"([^"]*nodes\/[^"]*)"/g,
|
|
12087
|
+
(whole, id) => {
|
|
12088
|
+
const label = describeNodeWith(titles, id);
|
|
12089
|
+
return `"${label.replace(/"/g, "")}"`;
|
|
12090
|
+
}
|
|
12091
|
+
);
|
|
12092
|
+
if (next === row.message) continue;
|
|
12093
|
+
await db.run("UPDATE notifications SET message = ? WHERE id = ?", [
|
|
12094
|
+
next,
|
|
12095
|
+
row.id
|
|
12096
|
+
]);
|
|
12097
|
+
rewritten++;
|
|
12098
|
+
}
|
|
12099
|
+
}
|
|
12100
|
+
await db.run(
|
|
12101
|
+
insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
|
|
12102
|
+
[NOTIFICATION_TITLES_MIGRATION_ID]
|
|
12103
|
+
);
|
|
12104
|
+
console.log(
|
|
12105
|
+
`[backfill] notifications: rewrote ${rewritten} of ${rows.length} message(s) carrying a node id`
|
|
12106
|
+
);
|
|
12107
|
+
}
|
|
12108
|
+
function describeNodeWith(titles, nodeId) {
|
|
12109
|
+
const at = nodeId.indexOf("nodes/");
|
|
12110
|
+
const normalized = at > 0 ? nodeId.slice(at) : nodeId;
|
|
12111
|
+
const title = titles.get(nodeId) || titles.get(normalized) || normalized.split("/").pop() || nodeId;
|
|
12112
|
+
const folder = folderForNode(normalized);
|
|
12113
|
+
return folder ? `${title} (in ${folder})` : title;
|
|
12114
|
+
}
|
|
12115
|
+
|
|
12116
|
+
// src/db/repair-version-chains.ts
|
|
12117
|
+
var MIGRATION_ID2 = "010_repair_grafted_version_chains";
|
|
12118
|
+
async function repairGraftedVersionChains(db) {
|
|
12119
|
+
const already = await db.get("SELECT id FROM schema_migrations WHERE id = ?", [
|
|
12120
|
+
MIGRATION_ID2
|
|
12121
|
+
]);
|
|
12122
|
+
if (already) return;
|
|
12123
|
+
const nests = await db.all("SELECT id FROM nests");
|
|
12124
|
+
let scanned = 0;
|
|
12125
|
+
let repaired = 0;
|
|
12126
|
+
let externalized = 0;
|
|
12127
|
+
for (const { id: nestId } of nests) {
|
|
12128
|
+
let storage;
|
|
12129
|
+
let versions;
|
|
12130
|
+
try {
|
|
12131
|
+
({ storage, versions } = await engineCache.get(nestId));
|
|
12132
|
+
} catch (err) {
|
|
12133
|
+
console.warn(
|
|
12134
|
+
`[repair] engine unavailable for nest ${nestId}:`,
|
|
12135
|
+
err.message
|
|
12136
|
+
);
|
|
12137
|
+
continue;
|
|
12138
|
+
}
|
|
12139
|
+
let docs;
|
|
12140
|
+
try {
|
|
12141
|
+
docs = await storage.discoverDocuments();
|
|
12142
|
+
} catch (err) {
|
|
12143
|
+
console.warn(
|
|
12144
|
+
`[repair] discoverDocuments failed for nest ${nestId}:`,
|
|
12145
|
+
err.message
|
|
12146
|
+
);
|
|
12147
|
+
continue;
|
|
12148
|
+
}
|
|
12149
|
+
for (const doc of docs) {
|
|
12150
|
+
scanned += 1;
|
|
12151
|
+
try {
|
|
12152
|
+
if (await versions.repairLatestVersion(doc.id)) {
|
|
12153
|
+
repaired += 1;
|
|
12154
|
+
console.log(`[repair] re-anchored version chain: ${nestId}/${doc.id}`);
|
|
12155
|
+
}
|
|
12156
|
+
externalized += await versions.externalizeDiffs(doc.id);
|
|
12157
|
+
} catch (err) {
|
|
12158
|
+
console.warn(
|
|
12159
|
+
`[repair] could not repair ${nestId}/${doc.id}:`,
|
|
12160
|
+
err.message
|
|
12161
|
+
);
|
|
12162
|
+
}
|
|
12163
|
+
}
|
|
12164
|
+
}
|
|
12165
|
+
await db.run(insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"), [
|
|
12166
|
+
MIGRATION_ID2
|
|
12167
|
+
]);
|
|
12168
|
+
console.log(
|
|
12169
|
+
`[repair] version chains: scanned ${scanned} docs across ${nests.length} nests, re-anchored ${repaired}, externalized ${externalized} inline change log(s)`
|
|
12170
|
+
);
|
|
12171
|
+
}
|
|
11163
12172
|
|
|
11164
12173
|
// src/workflow/scheduler.ts
|
|
11165
12174
|
import { v4 as uuid15 } from "uuid";
|
|
@@ -11237,6 +12246,16 @@ async function main() {
|
|
|
11237
12246
|
} catch (err) {
|
|
11238
12247
|
console.error("[backfill] node_versions backfill failed:", err);
|
|
11239
12248
|
}
|
|
12249
|
+
try {
|
|
12250
|
+
await repairGraftedVersionChains(db);
|
|
12251
|
+
} catch (err) {
|
|
12252
|
+
console.error("[repair] version chain repair failed:", err);
|
|
12253
|
+
}
|
|
12254
|
+
try {
|
|
12255
|
+
await backfillNotificationTitles(db);
|
|
12256
|
+
} catch (err) {
|
|
12257
|
+
console.error("[backfill] notification title backfill failed:", err);
|
|
12258
|
+
}
|
|
11240
12259
|
const accessCfg = loadAccessConfig();
|
|
11241
12260
|
if (accessCfg) {
|
|
11242
12261
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|