@promptowl/contextnest-community 1.14.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 +14 -0
- package/dist/{chunk-IHGPZ6NY.js → chunk-5IAKUUZF.js} +1 -1
- package/dist/{chunk-KPK656BH.js → chunk-7BOG3S5H.js} +563 -1006
- package/dist/{chunk-I5KYGMIT.js → chunk-BCFKLY4H.js} +54 -1
- package/dist/{chunk-MZ4IR5KH.js → chunk-C4U75IUR.js} +349 -32
- package/dist/chunk-GWDOLYFJ.js +624 -0
- package/dist/{chunk-JP67WXLA.js → chunk-JWVZ3T35.js} +58 -3
- package/dist/{client-3MGIP57D.js → client-NFUHPLLM.js} +1 -1
- package/dist/{grants-service-5ZFISCBH.js → grants-service-O4AO326A.js} +2 -2
- package/dist/index.js +1547 -657
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-JUAFYB73.js +47 -0
- package/dist/{stewardship-service-G7IF7ZNP.js → stewardship-service-FHJ2RBKN.js} +4 -4
- package/dist/{version-service-FGL47APL.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 +2 -2
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-6G7MVTY6.js +0 -31
- package/dist/web3/assets/index-BYmOkm4p.js +0 -1052
- package/dist/web3/assets/index-p6uQyhml.css +0 -1
package/dist/index.js
CHANGED
|
@@ -4,36 +4,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,
|
|
20
26
|
nodeWriteKey,
|
|
27
|
+
notifyDeletionResolved,
|
|
21
28
|
notifyNestEvent,
|
|
22
29
|
notifyNestInvite,
|
|
23
30
|
parseConditionSchema,
|
|
24
31
|
reject,
|
|
25
32
|
removeWatcher,
|
|
26
33
|
repointPendingReview,
|
|
34
|
+
requestDeletion,
|
|
27
35
|
requireWorkflowPlane,
|
|
28
36
|
resolveCallerEmail,
|
|
29
37
|
safeJson,
|
|
30
38
|
safePublishDocument,
|
|
31
39
|
seedDefaultEdgeTypes,
|
|
32
40
|
sendTestNotification,
|
|
41
|
+
serializeNodeSafe,
|
|
33
42
|
stripUndefinedDeep,
|
|
34
43
|
submitForReview,
|
|
35
44
|
withNodeWriteLock
|
|
36
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-C4U75IUR.js";
|
|
37
46
|
import {
|
|
38
47
|
createGrant,
|
|
39
48
|
deleteGrant,
|
|
@@ -42,7 +51,7 @@ import {
|
|
|
42
51
|
listGrants,
|
|
43
52
|
listUserGrants,
|
|
44
53
|
resolveNodeGrant
|
|
45
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-5IAKUUZF.js";
|
|
46
55
|
import {
|
|
47
56
|
generateApiKey,
|
|
48
57
|
getKeyPrefix,
|
|
@@ -58,19 +67,35 @@ import {
|
|
|
58
67
|
getCurrentVersion,
|
|
59
68
|
getDisplayStatus,
|
|
60
69
|
getVersions,
|
|
70
|
+
listMyDrafts,
|
|
61
71
|
setApprovedVersion,
|
|
62
72
|
upsertVersion
|
|
63
|
-
} from "./chunk-
|
|
73
|
+
} from "./chunk-JWVZ3T35.js";
|
|
64
74
|
import {
|
|
65
|
-
addMember,
|
|
66
|
-
buildTitleMap,
|
|
67
75
|
canCreateInNest,
|
|
68
76
|
canManageStewards,
|
|
69
77
|
canUserAccess,
|
|
70
78
|
canUserApprove,
|
|
71
79
|
canUserEdit,
|
|
72
|
-
createNest,
|
|
73
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,
|
|
74
99
|
createTeam,
|
|
75
100
|
deleteNest,
|
|
76
101
|
deleteTeam,
|
|
@@ -78,16 +103,14 @@ import {
|
|
|
78
103
|
docLink,
|
|
79
104
|
engineCache,
|
|
80
105
|
findTeamByExternalId,
|
|
106
|
+
folderForNode,
|
|
81
107
|
getAccessConfig,
|
|
82
|
-
getCollaboratorRole,
|
|
83
108
|
getCurrentLicense,
|
|
84
109
|
getNest,
|
|
85
|
-
getStewardRolesForUser,
|
|
86
|
-
getStewardsForNest,
|
|
87
|
-
getStewardsForUser,
|
|
88
110
|
getSuspensionReason,
|
|
89
111
|
getTeam,
|
|
90
112
|
importNest,
|
|
113
|
+
insertOrIgnore,
|
|
91
114
|
installLicenseKey,
|
|
92
115
|
isConfigSuperAdmin,
|
|
93
116
|
isLicenseAdminEmail,
|
|
@@ -101,7 +124,6 @@ import {
|
|
|
101
124
|
listNests,
|
|
102
125
|
listPublicNests,
|
|
103
126
|
listSharedNests,
|
|
104
|
-
listStewards,
|
|
105
127
|
listTeamsForUser,
|
|
106
128
|
listVisibleNests,
|
|
107
129
|
loadAccessConfig,
|
|
@@ -109,36 +131,37 @@ import {
|
|
|
109
131
|
loadServerSettings,
|
|
110
132
|
nestAllowsSelfApprove,
|
|
111
133
|
nestName,
|
|
134
|
+
nestPrimeTags,
|
|
135
|
+
nestReviewsPrimeOnly,
|
|
112
136
|
nestStorageRoot,
|
|
137
|
+
normalizeTag,
|
|
138
|
+
nowExpr,
|
|
113
139
|
permissionLevel,
|
|
114
140
|
persistSetting,
|
|
115
141
|
removeMember,
|
|
116
|
-
|
|
142
|
+
removeNodeFromTagIndex,
|
|
117
143
|
renameNest,
|
|
118
144
|
renameTeam,
|
|
119
145
|
replaceTeamRoster,
|
|
120
|
-
resolveGrantRoles,
|
|
121
146
|
resolveMemberIdentity,
|
|
122
147
|
resolveNestPath,
|
|
123
148
|
resolveNestPermission,
|
|
124
|
-
resolveStewardsForNode,
|
|
125
|
-
resolveStewardsWithFallback,
|
|
126
|
-
resolveUserRoles,
|
|
127
149
|
sendEmailToRecipient,
|
|
128
150
|
setAllowSelfApprove,
|
|
151
|
+
setPrimeOnlyReview,
|
|
152
|
+
setPrimeTags,
|
|
129
153
|
setStewardshipEnabled,
|
|
130
154
|
shareTeamWithNest,
|
|
131
155
|
startLicenseSafetyPoll,
|
|
132
156
|
startTelemetryLoop,
|
|
133
|
-
|
|
157
|
+
syncNodeTags,
|
|
134
158
|
trackEvent,
|
|
135
159
|
uniqueNestName,
|
|
136
160
|
unshareTeamFromNest,
|
|
137
161
|
updateMemberRole,
|
|
138
|
-
updateSteward,
|
|
139
162
|
validateLicense,
|
|
140
163
|
verifySmtp
|
|
141
|
-
} from "./chunk-
|
|
164
|
+
} from "./chunk-7BOG3S5H.js";
|
|
142
165
|
import {
|
|
143
166
|
AppError,
|
|
144
167
|
ConflictError,
|
|
@@ -148,24 +171,20 @@ import {
|
|
|
148
171
|
UnauthorizedError,
|
|
149
172
|
ValidationError
|
|
150
173
|
} from "./chunk-GUNJTORH.js";
|
|
151
|
-
import {
|
|
152
|
-
normalizeEmail
|
|
153
|
-
} from "./chunk-FRQJWGN3.js";
|
|
154
|
-
import {
|
|
155
|
-
insertOrIgnore,
|
|
156
|
-
nowExpr
|
|
157
|
-
} from "./chunk-XQ46F76G.js";
|
|
158
174
|
import {
|
|
159
175
|
config,
|
|
160
176
|
getDb,
|
|
161
177
|
initDb,
|
|
162
178
|
isEmailListish,
|
|
163
179
|
isEmailish
|
|
164
|
-
} from "./chunk-
|
|
180
|
+
} from "./chunk-BCFKLY4H.js";
|
|
165
181
|
import {
|
|
166
182
|
ANON_EMAIL,
|
|
167
183
|
ANON_USER_ID
|
|
168
184
|
} from "./chunk-SLTQACJW.js";
|
|
185
|
+
import {
|
|
186
|
+
normalizeEmail
|
|
187
|
+
} from "./chunk-FRQJWGN3.js";
|
|
169
188
|
|
|
170
189
|
// src/index.ts
|
|
171
190
|
import { serve } from "@hono/node-server";
|
|
@@ -393,6 +412,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
|
|
|
393
412
|
|
|
394
413
|
// src/auth/sso-token.ts
|
|
395
414
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
415
|
+
function isValidTicketSub(sub) {
|
|
416
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
|
|
417
|
+
}
|
|
396
418
|
function b64urlToBuffer(input) {
|
|
397
419
|
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
398
420
|
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
@@ -1215,6 +1237,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1215
1237
|
} catch {
|
|
1216
1238
|
return ssoError("invalid_ticket");
|
|
1217
1239
|
}
|
|
1240
|
+
if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
|
|
1218
1241
|
const expectedAud = config.PUBLIC_BASE_URL;
|
|
1219
1242
|
if (!expectedAud && !warnedMissingSsoAudience) {
|
|
1220
1243
|
warnedMissingSsoAudience = true;
|
|
@@ -1227,7 +1250,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
1227
1250
|
if (aud !== expectedAud) return ssoError("bad_audience");
|
|
1228
1251
|
}
|
|
1229
1252
|
const sub = (claims.sub || "").trim();
|
|
1230
|
-
if (
|
|
1253
|
+
if (!isValidTicketSub(sub)) {
|
|
1231
1254
|
return ssoError("invalid_ticket");
|
|
1232
1255
|
}
|
|
1233
1256
|
if (!claims.jti) return ssoError("invalid_ticket");
|
|
@@ -1563,42 +1586,27 @@ import { streamText } from "hono/streaming";
|
|
|
1563
1586
|
import {
|
|
1564
1587
|
serializeDocument,
|
|
1565
1588
|
parseDocument as parseDocument2,
|
|
1566
|
-
normalizeStatus
|
|
1589
|
+
normalizeStatus,
|
|
1590
|
+
DocumentNotFoundError
|
|
1567
1591
|
} from "@promptowl/contextnest-engine";
|
|
1568
1592
|
import { rename, mkdir, rm } from "fs/promises";
|
|
1569
1593
|
import { dirname, join as join2, basename } from "path";
|
|
1570
1594
|
|
|
1571
|
-
// src/governance/
|
|
1572
|
-
function
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1579
|
-
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1580
|
-
)
|
|
1581
|
-
);
|
|
1582
|
-
const insertSql = insertOrIgnore(
|
|
1583
|
-
db,
|
|
1584
|
-
"INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1585
|
-
);
|
|
1586
|
-
await db.transaction(async (tx) => {
|
|
1587
|
-
await tx.run(
|
|
1588
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1589
|
-
[nestId, nodeId]
|
|
1590
|
-
);
|
|
1591
|
-
for (const tag of normalized) {
|
|
1592
|
-
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1593
|
-
}
|
|
1594
|
-
});
|
|
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))));
|
|
1595
1603
|
}
|
|
1596
|
-
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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;
|
|
1602
1610
|
}
|
|
1603
1611
|
|
|
1604
1612
|
// src/governance/external-edit-service.ts
|
|
@@ -1784,7 +1792,7 @@ async function approveExternalEdit(input) {
|
|
|
1784
1792
|
const node = await storage.readDocument(input.documentId);
|
|
1785
1793
|
const versionNum = result.versionEntry.version;
|
|
1786
1794
|
const tags = node.frontmatter.tags || [];
|
|
1787
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1795
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
|
|
1788
1796
|
await createVersion2({
|
|
1789
1797
|
nestId: input.nestId,
|
|
1790
1798
|
nodeId: input.documentId,
|
|
@@ -1865,9 +1873,21 @@ async function userIdFromEmail(email) {
|
|
|
1865
1873
|
return row?.id ?? ANON_USER_ID;
|
|
1866
1874
|
}
|
|
1867
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
|
+
}
|
|
1868
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
|
+
}
|
|
1869
1888
|
function folderToSegments(folder) {
|
|
1870
1889
|
const segments = folder.split("/").map(slugify).filter(Boolean);
|
|
1890
|
+
if (segments[0] === "nodes") segments.shift();
|
|
1871
1891
|
if (segments.length > 8) {
|
|
1872
1892
|
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1873
1893
|
}
|
|
@@ -1928,7 +1948,8 @@ function toNodeResponse(node) {
|
|
|
1928
1948
|
pendingChange: node.pendingChange ?? void 0,
|
|
1929
1949
|
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1930
1950
|
// number — normalize to string at the API boundary.
|
|
1931
|
-
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)
|
|
1932
1953
|
};
|
|
1933
1954
|
}
|
|
1934
1955
|
async function resolveAuthorship(nestId, nodes) {
|
|
@@ -2069,17 +2090,29 @@ async function actorAutoPublishes(nestId, userEmail) {
|
|
|
2069
2090
|
const roles = await resolveUserRoles(nestId, userEmail);
|
|
2070
2091
|
return roles.includes("owner") || roles.includes("admin");
|
|
2071
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
|
+
}
|
|
2072
2109
|
async function createNode(nestId, input, userEmail) {
|
|
2073
2110
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2074
|
-
|
|
2111
|
+
assertTitleLength(input.title);
|
|
2112
|
+
const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
|
|
2075
2113
|
let id = input.id;
|
|
2076
2114
|
if (!id) {
|
|
2077
2115
|
const folderSegments = folderToSegments(input.folder ?? "");
|
|
2078
|
-
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
2079
|
-
throw new ValidationError(
|
|
2080
|
-
"each folder or title segment must be at most 100 characters"
|
|
2081
|
-
);
|
|
2082
|
-
}
|
|
2083
2116
|
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
2084
2117
|
}
|
|
2085
2118
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2098,72 +2131,75 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2098
2131
|
)})`
|
|
2099
2132
|
);
|
|
2100
2133
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
tags.push(deptTag);
|
|
2104
|
-
}
|
|
2105
|
-
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2106
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2107
|
-
const initialStatus = autoPublish ? "published" : "draft";
|
|
2108
|
-
const initialVersion = autoPublish ? 0 : 1;
|
|
2109
|
-
let node = {
|
|
2110
|
-
id,
|
|
2111
|
-
filePath: "",
|
|
2112
|
-
frontmatter: {
|
|
2113
|
-
title: input.title,
|
|
2114
|
-
type: requestedType,
|
|
2115
|
-
tags,
|
|
2116
|
-
status: input.status || initialStatus,
|
|
2117
|
-
version: initialVersion,
|
|
2118
|
-
created_at: now,
|
|
2119
|
-
updated_at: now,
|
|
2120
|
-
metadata: {
|
|
2121
|
-
owners: ["*"],
|
|
2122
|
-
scope: input.scope || "team",
|
|
2123
|
-
...input.schedule ? { schedule: input.schedule } : {}
|
|
2124
|
-
}
|
|
2125
|
-
},
|
|
2126
|
-
body: input.content,
|
|
2127
|
-
rawContent: ""
|
|
2128
|
-
};
|
|
2129
|
-
await storage.writeDocument(id, serializeDocument(node));
|
|
2130
|
-
await syncNodeTags(nestId, id, tags);
|
|
2131
|
-
let savedVersion = 1;
|
|
2132
|
-
if (!autoPublish) {
|
|
2134
|
+
return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
|
|
2135
|
+
let occupied = false;
|
|
2133
2136
|
try {
|
|
2134
|
-
await
|
|
2137
|
+
await storage.readDocument(id);
|
|
2138
|
+
occupied = true;
|
|
2135
2139
|
} catch (err) {
|
|
2136
|
-
|
|
2140
|
+
if (!(err instanceof DocumentNotFoundError)) throw err;
|
|
2137
2141
|
}
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
await createVersion({
|
|
2155
|
-
nestId,
|
|
2156
|
-
nodeId: id,
|
|
2157
|
-
version: savedVersion,
|
|
2158
|
-
content: result.node.body || "",
|
|
2159
|
-
author: userEmail,
|
|
2160
|
-
status: "published",
|
|
2161
|
-
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);
|
|
2162
2158
|
});
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
+
}
|
|
2167
2203
|
await createVersion({
|
|
2168
2204
|
nestId,
|
|
2169
2205
|
nodeId: id,
|
|
@@ -2173,10 +2209,42 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2173
2209
|
status: "draft",
|
|
2174
2210
|
tags
|
|
2175
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
|
+
}
|
|
2176
2244
|
}
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2245
|
+
await trackEvent("node.create", { nestId, nodeId: id });
|
|
2246
|
+
return { node, version: savedVersion };
|
|
2247
|
+
});
|
|
2180
2248
|
}
|
|
2181
2249
|
function explicitFrontmatterStatus(raw) {
|
|
2182
2250
|
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
@@ -2196,7 +2264,7 @@ async function resetImportedDocForPublish(storage, nestId, nodeId) {
|
|
|
2196
2264
|
const fm = { ...node.frontmatter };
|
|
2197
2265
|
delete fm.version;
|
|
2198
2266
|
delete fm.checksum;
|
|
2199
|
-
await storage.writeDocument(nodeId,
|
|
2267
|
+
await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
|
|
2200
2268
|
}
|
|
2201
2269
|
async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
2202
2270
|
const { storage } = await engineCache.get(nestId);
|
|
@@ -2226,7 +2294,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
|
2226
2294
|
const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
|
|
2227
2295
|
doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
|
|
2228
2296
|
try {
|
|
2229
|
-
await storage.writeDocument(nodeId,
|
|
2297
|
+
await storage.writeDocument(nodeId, serializeNodeSafe(doc));
|
|
2230
2298
|
} catch (err) {
|
|
2231
2299
|
console.error("import: failed to persist import metadata", nodeId, err);
|
|
2232
2300
|
}
|
|
@@ -2338,6 +2406,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2338
2406
|
};
|
|
2339
2407
|
}
|
|
2340
2408
|
if (patch.title) {
|
|
2409
|
+
if (patch.title !== node.frontmatter.title) {
|
|
2410
|
+
assertTitleLength(patch.title);
|
|
2411
|
+
}
|
|
2341
2412
|
node = {
|
|
2342
2413
|
...node,
|
|
2343
2414
|
frontmatter: { ...node.frontmatter, title: patch.title }
|
|
@@ -2356,9 +2427,19 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2356
2427
|
else metadata.schedule = patch.schedule;
|
|
2357
2428
|
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2358
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
|
+
}
|
|
2359
2437
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2360
|
-
const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
|
|
2361
2438
|
const currentTags = node.frontmatter.tags || [];
|
|
2439
|
+
const publishReason = await autoPublishReason(nestId, userEmail, {
|
|
2440
|
+
tags: currentTags,
|
|
2441
|
+
metadata: node.frontmatter.metadata
|
|
2442
|
+
});
|
|
2362
2443
|
const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
|
|
2363
2444
|
const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
|
|
2364
2445
|
if (pendingReview && !isAuthorEditingOwnReview) {
|
|
@@ -2367,9 +2448,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2367
2448
|
);
|
|
2368
2449
|
}
|
|
2369
2450
|
let responseVersion;
|
|
2370
|
-
if (
|
|
2451
|
+
if (publishReason === null || isAuthorEditingOwnReview) {
|
|
2371
2452
|
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
2372
|
-
const newVersion =
|
|
2453
|
+
const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
|
|
2373
2454
|
node = {
|
|
2374
2455
|
...node,
|
|
2375
2456
|
frontmatter: {
|
|
@@ -2422,10 +2503,11 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2422
2503
|
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2423
2504
|
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2424
2505
|
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2506
|
+
const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
|
|
2425
2507
|
try {
|
|
2426
2508
|
const result = await safePublishDocument(storage, nodeId, {
|
|
2427
2509
|
editedBy: userEmail,
|
|
2428
|
-
note:
|
|
2510
|
+
note: publishNote
|
|
2429
2511
|
});
|
|
2430
2512
|
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
2431
2513
|
node = result.node;
|
|
@@ -2444,7 +2526,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2444
2526
|
author: userEmail,
|
|
2445
2527
|
status: "published",
|
|
2446
2528
|
tags: currentTags,
|
|
2447
|
-
changeNote:
|
|
2529
|
+
changeNote: publishNote
|
|
2448
2530
|
});
|
|
2449
2531
|
await setApprovedVersion(
|
|
2450
2532
|
nestId,
|
|
@@ -2464,9 +2546,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2464
2546
|
}
|
|
2465
2547
|
});
|
|
2466
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
|
+
}
|
|
2467
2557
|
function derivedIdOf(sourceNodeId) {
|
|
2468
2558
|
return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
|
|
2469
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
|
+
}
|
|
2470
2612
|
async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
2471
2613
|
const { storage } = await engineCache.get(nestId);
|
|
2472
2614
|
let node;
|
|
@@ -2475,103 +2617,124 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
|
2475
2617
|
} catch {
|
|
2476
2618
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2477
2619
|
}
|
|
2478
|
-
|
|
2479
|
-
throw new LockedError(
|
|
2480
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
|
|
2481
|
-
);
|
|
2482
|
-
}
|
|
2620
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
|
|
2483
2621
|
const leaf = nodeId.split("/").pop();
|
|
2484
2622
|
const segments = folderToSegments(folder);
|
|
2485
2623
|
const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
|
|
2486
2624
|
if (newId === nodeId) return { node, previousId: nodeId };
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
...node,
|
|
2497
|
-
id: newId,
|
|
2498
|
-
frontmatter: {
|
|
2499
|
-
...node.frontmatter,
|
|
2500
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
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;
|
|
2501
2634
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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 {
|
|
2511
2653
|
}
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
for (const [table, column] of [
|
|
2520
|
-
["node_versions", "node_id"],
|
|
2521
|
-
["review_requests", "node_id"],
|
|
2522
|
-
["approved_versions", "node_id"],
|
|
2523
|
-
["annotation_threads", "node_id"],
|
|
2524
|
-
["comments", "node_id"],
|
|
2525
|
-
["node_tag_index", "node_id"]
|
|
2526
|
-
]) {
|
|
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);
|
|
2527
2661
|
await tx.run(
|
|
2528
|
-
|
|
2662
|
+
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
2529
2663
|
[newId, nestId, nodeId]
|
|
2530
2664
|
);
|
|
2531
2665
|
await tx.run(
|
|
2532
|
-
`UPDATE
|
|
2533
|
-
|
|
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]
|
|
2669
|
+
);
|
|
2670
|
+
});
|
|
2671
|
+
try {
|
|
2672
|
+
const derivedDoc = await storage.readDocument(oldDerived);
|
|
2673
|
+
await storage.writeDocument(
|
|
2674
|
+
newDerived,
|
|
2675
|
+
serializeNodeSafe({ ...derivedDoc, id: newDerived })
|
|
2534
2676
|
);
|
|
2677
|
+
await storage.deleteDocument(oldDerived);
|
|
2678
|
+
} catch {
|
|
2535
2679
|
}
|
|
2536
|
-
await
|
|
2537
|
-
|
|
2538
|
-
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2539
|
-
[newId, nestId, nodeId]
|
|
2540
|
-
);
|
|
2541
|
-
await tx.run(
|
|
2542
|
-
`UPDATE grants SET target = ?
|
|
2543
|
-
WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
|
|
2544
|
-
[newId, nestId, nodeId]
|
|
2545
|
-
);
|
|
2546
|
-
await tx.run(
|
|
2547
|
-
"UPDATE edges SET from_node = ? WHERE nest_id = ? AND from_node = ?",
|
|
2548
|
-
[newId, nestId, nodeId]
|
|
2549
|
-
);
|
|
2550
|
-
await tx.run(
|
|
2551
|
-
"UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
|
|
2552
|
-
[newId, nestId, nodeId]
|
|
2553
|
-
);
|
|
2554
|
-
await tx.run(
|
|
2555
|
-
"UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
|
|
2556
|
-
[newId, nestId, nodeId]
|
|
2557
|
-
);
|
|
2558
|
-
await tx.run(
|
|
2559
|
-
`UPDATE run_steps SET node_id = ?
|
|
2560
|
-
WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
|
|
2561
|
-
[newId, nodeId, nestId]
|
|
2562
|
-
);
|
|
2680
|
+
await trackEvent("node.move", { nestId, nodeId, newId });
|
|
2681
|
+
return { node: moved, previousId: nodeId };
|
|
2563
2682
|
});
|
|
2683
|
+
}
|
|
2684
|
+
async function deleteNode(nestId, nodeId, userEmail) {
|
|
2685
|
+
const { storage } = await engineCache.get(nestId);
|
|
2686
|
+
await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
|
|
2564
2687
|
try {
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
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);
|
|
2571
2697
|
} catch {
|
|
2572
2698
|
}
|
|
2573
|
-
|
|
2574
|
-
|
|
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;
|
|
2575
2738
|
}
|
|
2576
2739
|
|
|
2577
2740
|
// src/nests/unsynced-service.ts
|
|
@@ -3023,7 +3186,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
|
|
|
3023
3186
|
}
|
|
3024
3187
|
return c.json({
|
|
3025
3188
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3026
|
-
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)
|
|
3027
3192
|
});
|
|
3028
3193
|
});
|
|
3029
3194
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
@@ -3059,9 +3224,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
3059
3224
|
if (typeof body.allow_self_approve === "boolean") {
|
|
3060
3225
|
await setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
3061
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
|
+
}
|
|
3062
3236
|
return c.json({
|
|
3063
3237
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
3064
3238
|
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
3239
|
+
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
3240
|
+
prime_tags: await nestPrimeTags(nestId),
|
|
3065
3241
|
wiped
|
|
3066
3242
|
});
|
|
3067
3243
|
});
|
|
@@ -3732,294 +3908,6 @@ ${m[1]}
|
|
|
3732
3908
|
` : "";
|
|
3733
3909
|
}
|
|
3734
3910
|
|
|
3735
|
-
// src/annotations/service.ts
|
|
3736
|
-
import { v4 as uuid5 } from "uuid";
|
|
3737
|
-
|
|
3738
|
-
// src/annotations/projection.ts
|
|
3739
|
-
var MAX_CONTEXT_CHARS = 250;
|
|
3740
|
-
var MAX_QUOTE_CHARS = 1e3;
|
|
3741
|
-
function clampAnchor(raw) {
|
|
3742
|
-
if (!raw) return null;
|
|
3743
|
-
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
3744
|
-
if (!quote) return null;
|
|
3745
|
-
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
3746
|
-
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
3747
|
-
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
3748
|
-
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
3749
|
-
}
|
|
3750
|
-
function oneLine(s) {
|
|
3751
|
-
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
3752
|
-
}
|
|
3753
|
-
function renderAnchor(anchor) {
|
|
3754
|
-
if (!anchor) {
|
|
3755
|
-
return "<!-- anchor: whole-artifact -->";
|
|
3756
|
-
}
|
|
3757
|
-
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
3758
|
-
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
3759
|
-
anchor.after
|
|
3760
|
-
)}\u2026`;
|
|
3761
|
-
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
3762
|
-
}
|
|
3763
|
-
function statusHeading(thread) {
|
|
3764
|
-
if (thread.status === "resolved") {
|
|
3765
|
-
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
3766
|
-
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
3767
|
-
const meta = who || when ? ` (${who}${when})` : "";
|
|
3768
|
-
return `\u2705 RESOLVED${meta}`;
|
|
3769
|
-
}
|
|
3770
|
-
return "\u{1F7E0} OPEN";
|
|
3771
|
-
}
|
|
3772
|
-
function snapshotLabel(v) {
|
|
3773
|
-
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
3774
|
-
}
|
|
3775
|
-
function orderThreads(threads) {
|
|
3776
|
-
return [...threads].sort((a, b) => {
|
|
3777
|
-
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
3778
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
3779
|
-
});
|
|
3780
|
-
}
|
|
3781
|
-
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
3782
|
-
const lines = [];
|
|
3783
|
-
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
3784
|
-
lines.push("");
|
|
3785
|
-
lines.push(
|
|
3786
|
-
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
3787
|
-
);
|
|
3788
|
-
lines.push("");
|
|
3789
|
-
if (threads.length === 0) {
|
|
3790
|
-
lines.push("_No annotations yet._");
|
|
3791
|
-
lines.push("");
|
|
3792
|
-
return lines.join("\n");
|
|
3793
|
-
}
|
|
3794
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
3795
|
-
for (const t of threads) {
|
|
3796
|
-
const key = t.snapshotVersion ?? null;
|
|
3797
|
-
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
3798
|
-
}
|
|
3799
|
-
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
3800
|
-
if (a == null) return 1;
|
|
3801
|
-
if (b == null) return -1;
|
|
3802
|
-
return b - a;
|
|
3803
|
-
});
|
|
3804
|
-
for (const key of keys) {
|
|
3805
|
-
lines.push(`## ${snapshotLabel(key)}`);
|
|
3806
|
-
lines.push("");
|
|
3807
|
-
for (const thread of orderThreads(buckets2.get(key))) {
|
|
3808
|
-
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
3809
|
-
lines.push(renderAnchor(thread.anchor));
|
|
3810
|
-
for (const comment of thread.comments) {
|
|
3811
|
-
lines.push(
|
|
3812
|
-
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
3813
|
-
);
|
|
3814
|
-
}
|
|
3815
|
-
lines.push("");
|
|
3816
|
-
}
|
|
3817
|
-
}
|
|
3818
|
-
return lines.join("\n");
|
|
3819
|
-
}
|
|
3820
|
-
|
|
3821
|
-
// src/annotations/service.ts
|
|
3822
|
-
async function loadComments(threadId) {
|
|
3823
|
-
const db = getDb();
|
|
3824
|
-
const rows = await db.all(
|
|
3825
|
-
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
3826
|
-
[threadId]
|
|
3827
|
-
);
|
|
3828
|
-
return rows.map((r) => ({
|
|
3829
|
-
id: r.id,
|
|
3830
|
-
author: r.author,
|
|
3831
|
-
body: r.body,
|
|
3832
|
-
createdAt: r.created_at
|
|
3833
|
-
}));
|
|
3834
|
-
}
|
|
3835
|
-
async function rowToThread(row) {
|
|
3836
|
-
let anchor = null;
|
|
3837
|
-
if (row.anchor_json) {
|
|
3838
|
-
try {
|
|
3839
|
-
anchor = JSON.parse(row.anchor_json);
|
|
3840
|
-
} catch {
|
|
3841
|
-
anchor = null;
|
|
3842
|
-
}
|
|
3843
|
-
}
|
|
3844
|
-
return {
|
|
3845
|
-
id: row.id,
|
|
3846
|
-
nestId: row.nest_id,
|
|
3847
|
-
nodeId: row.node_id,
|
|
3848
|
-
snapshotVersion: row.snapshot_version,
|
|
3849
|
-
anchor,
|
|
3850
|
-
status: row.status,
|
|
3851
|
-
createdBy: row.created_by,
|
|
3852
|
-
createdAt: row.created_at,
|
|
3853
|
-
resolvedBy: row.resolved_by,
|
|
3854
|
-
resolvedAt: row.resolved_at,
|
|
3855
|
-
comments: await loadComments(row.id)
|
|
3856
|
-
};
|
|
3857
|
-
}
|
|
3858
|
-
async function getThreadRow(threadId) {
|
|
3859
|
-
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
3860
|
-
}
|
|
3861
|
-
async function countThreadsByNode(nestId) {
|
|
3862
|
-
const rows = await getDb().all(
|
|
3863
|
-
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
3864
|
-
FROM annotation_threads
|
|
3865
|
-
WHERE nest_id = ?
|
|
3866
|
-
GROUP BY node_id`,
|
|
3867
|
-
[nestId]
|
|
3868
|
-
);
|
|
3869
|
-
const out = {};
|
|
3870
|
-
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
3871
|
-
return out;
|
|
3872
|
-
}
|
|
3873
|
-
async function listThreads(nestId, nodeId) {
|
|
3874
|
-
const rows = await getDb().all(
|
|
3875
|
-
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
3876
|
-
[nestId, nodeId]
|
|
3877
|
-
);
|
|
3878
|
-
return Promise.all(rows.map(rowToThread));
|
|
3879
|
-
}
|
|
3880
|
-
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
3881
|
-
const body = (input.body ?? "").trim();
|
|
3882
|
-
if (!body) {
|
|
3883
|
-
throw new Error("comment body is required");
|
|
3884
|
-
}
|
|
3885
|
-
const db = getDb();
|
|
3886
|
-
const id = uuid5();
|
|
3887
|
-
const anchor = clampAnchor(input.anchor);
|
|
3888
|
-
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3889
|
-
await db.transaction(async (tx) => {
|
|
3890
|
-
await tx.run(
|
|
3891
|
-
`INSERT INTO annotation_threads
|
|
3892
|
-
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
3893
|
-
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
3894
|
-
[
|
|
3895
|
-
id,
|
|
3896
|
-
nestId,
|
|
3897
|
-
nodeId,
|
|
3898
|
-
snapshot,
|
|
3899
|
-
anchor ? JSON.stringify(anchor) : null,
|
|
3900
|
-
authorEmail
|
|
3901
|
-
]
|
|
3902
|
-
);
|
|
3903
|
-
await tx.run(
|
|
3904
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3905
|
-
[uuid5(), id, authorEmail, body]
|
|
3906
|
-
);
|
|
3907
|
-
});
|
|
3908
|
-
return rowToThread(await getThreadRow(id));
|
|
3909
|
-
}
|
|
3910
|
-
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
3911
|
-
const row = await getThreadRow(threadId);
|
|
3912
|
-
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
3913
|
-
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
3914
|
-
}
|
|
3915
|
-
return row;
|
|
3916
|
-
}
|
|
3917
|
-
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
3918
|
-
const trimmed = (body ?? "").trim();
|
|
3919
|
-
if (!trimmed) {
|
|
3920
|
-
throw new Error("comment body is required");
|
|
3921
|
-
}
|
|
3922
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3923
|
-
await getDb().run(
|
|
3924
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
3925
|
-
[uuid5(), threadId, authorEmail, trimmed]
|
|
3926
|
-
);
|
|
3927
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3928
|
-
}
|
|
3929
|
-
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
3930
|
-
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
3931
|
-
const db = getDb();
|
|
3932
|
-
if (status === "resolved") {
|
|
3933
|
-
await db.run(
|
|
3934
|
-
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
3935
|
-
[byEmail, threadId]
|
|
3936
|
-
);
|
|
3937
|
-
} else {
|
|
3938
|
-
await db.run(
|
|
3939
|
-
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
3940
|
-
[threadId]
|
|
3941
|
-
);
|
|
3942
|
-
}
|
|
3943
|
-
return rowToThread(await getThreadRow(threadId));
|
|
3944
|
-
}
|
|
3945
|
-
async function readArtifact(nestId, nodeId, version) {
|
|
3946
|
-
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
3947
|
-
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
3948
|
-
let title = nodeId;
|
|
3949
|
-
let type = null;
|
|
3950
|
-
let liveHtml = "";
|
|
3951
|
-
let liveExists = true;
|
|
3952
|
-
try {
|
|
3953
|
-
const node = await storage.readDocument(nodeId);
|
|
3954
|
-
title = node.frontmatter?.title || nodeId;
|
|
3955
|
-
type = node.frontmatter?.type || null;
|
|
3956
|
-
liveHtml = node.body || "";
|
|
3957
|
-
} catch {
|
|
3958
|
-
liveExists = false;
|
|
3959
|
-
}
|
|
3960
|
-
if (target != null) {
|
|
3961
|
-
try {
|
|
3962
|
-
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
3963
|
-
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
3964
|
-
} catch (err) {
|
|
3965
|
-
console.warn(
|
|
3966
|
-
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
3967
|
-
err
|
|
3968
|
-
);
|
|
3969
|
-
}
|
|
3970
|
-
}
|
|
3971
|
-
if (!liveExists) {
|
|
3972
|
-
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
3973
|
-
}
|
|
3974
|
-
return { title, html: liveHtml, version: target, type };
|
|
3975
|
-
}
|
|
3976
|
-
function derivedAnnotationsId(sourceNodeId) {
|
|
3977
|
-
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
3978
|
-
return `nodes/${base}--annotations`;
|
|
3979
|
-
}
|
|
3980
|
-
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
3981
|
-
try {
|
|
3982
|
-
let title = nodeId;
|
|
3983
|
-
let type = null;
|
|
3984
|
-
try {
|
|
3985
|
-
const { storage } = await engineCache.get(nestId);
|
|
3986
|
-
const node = await storage.readDocument(nodeId);
|
|
3987
|
-
title = node.frontmatter?.title || nodeId;
|
|
3988
|
-
type = node.frontmatter?.type || "document";
|
|
3989
|
-
} catch {
|
|
3990
|
-
}
|
|
3991
|
-
if (type !== null && type !== "artifact") return;
|
|
3992
|
-
const threads = await listThreads(nestId, nodeId);
|
|
3993
|
-
const derivedTitle = `${title} \u2014 Annotations`;
|
|
3994
|
-
const markdown = projectThreadsToMarkdown(title, threads);
|
|
3995
|
-
const derivedId = derivedAnnotationsId(nodeId);
|
|
3996
|
-
try {
|
|
3997
|
-
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
3998
|
-
} catch (err) {
|
|
3999
|
-
if (err instanceof NotFoundError) {
|
|
4000
|
-
await createNode(
|
|
4001
|
-
nestId,
|
|
4002
|
-
{
|
|
4003
|
-
id: derivedId,
|
|
4004
|
-
title: derivedTitle,
|
|
4005
|
-
content: markdown,
|
|
4006
|
-
type: "document",
|
|
4007
|
-
tags: ["annotations"]
|
|
4008
|
-
},
|
|
4009
|
-
userEmail
|
|
4010
|
-
);
|
|
4011
|
-
} else {
|
|
4012
|
-
throw err;
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
} catch (err) {
|
|
4016
|
-
console.warn(
|
|
4017
|
-
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
4018
|
-
err
|
|
4019
|
-
);
|
|
4020
|
-
}
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
3911
|
// src/nodes/routes.ts
|
|
4024
3912
|
var nodeRoutes = new Hono8();
|
|
4025
3913
|
function nodeAsMarkdown(response, nodeId) {
|
|
@@ -4068,7 +3956,8 @@ nodeRoutes.post("/", async (c) => {
|
|
|
4068
3956
|
scope: body.scope,
|
|
4069
3957
|
status: body.status,
|
|
4070
3958
|
schedule: body.schedule,
|
|
4071
|
-
folder: body.folder
|
|
3959
|
+
folder: body.folder,
|
|
3960
|
+
prime: body.prime
|
|
4072
3961
|
},
|
|
4073
3962
|
authorEmail
|
|
4074
3963
|
);
|
|
@@ -4085,7 +3974,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
4085
3974
|
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
4086
3975
|
const nestId = c.req.param("nestId");
|
|
4087
3976
|
const nodeId = c.req.param("nodeId");
|
|
4088
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
3977
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
|
|
4089
3978
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
4090
3979
|
nestId,
|
|
4091
3980
|
nodeId
|
|
@@ -4103,42 +3992,10 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
4103
3992
|
ownerEmail
|
|
4104
3993
|
});
|
|
4105
3994
|
});
|
|
4106
|
-
nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
4107
|
-
const nestId = c.req.param("nestId");
|
|
4108
|
-
const nodeId = c.req.param("nodeId");
|
|
4109
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FGL47APL.js");
|
|
4110
|
-
const allVersions = await getVersions2(nestId, nodeId);
|
|
4111
|
-
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
4112
|
-
const db = getDb();
|
|
4113
|
-
const resolutions = await db.all(
|
|
4114
|
-
`SELECT version, status, resolved_by, resolved_at
|
|
4115
|
-
FROM review_requests
|
|
4116
|
-
WHERE nest_id = ? AND node_id = ?
|
|
4117
|
-
AND status IN ('approved', 'rejected')
|
|
4118
|
-
AND resolved_by IS NOT NULL
|
|
4119
|
-
ORDER BY resolved_at DESC`,
|
|
4120
|
-
[nestId, nodeId]
|
|
4121
|
-
);
|
|
4122
|
-
const byVersion = /* @__PURE__ */ new Map();
|
|
4123
|
-
for (const r of resolutions) {
|
|
4124
|
-
if (!byVersion.has(r.version)) {
|
|
4125
|
-
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
const enriched = allVersions.map((v) => {
|
|
4129
|
-
const r = byVersion.get(v.version);
|
|
4130
|
-
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
4131
|
-
});
|
|
4132
|
-
return c.json({
|
|
4133
|
-
versions: enriched,
|
|
4134
|
-
approvedVersion: approved,
|
|
4135
|
-
currentVersion: allVersions[0]?.version || 0
|
|
4136
|
-
});
|
|
4137
|
-
});
|
|
4138
3995
|
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
4139
3996
|
const nestId = c.req.param("nestId");
|
|
4140
3997
|
const nodeId = c.req.param("nodeId");
|
|
4141
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
3998
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
|
|
4142
3999
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
4143
4000
|
return c.json({ reviews: history });
|
|
4144
4001
|
});
|
|
@@ -4304,6 +4161,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
4304
4161
|
const pending = await getPendingReview(nestId, nodeId);
|
|
4305
4162
|
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
4306
4163
|
}
|
|
4164
|
+
response.deletionRequest = await getLatestDeletionRequest(
|
|
4165
|
+
nestId,
|
|
4166
|
+
nodeId
|
|
4167
|
+
);
|
|
4307
4168
|
if (isRawFormat(c)) {
|
|
4308
4169
|
return rawNodeBody(c, response, node.rawContent);
|
|
4309
4170
|
}
|
|
@@ -4347,7 +4208,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
4347
4208
|
title: body.title,
|
|
4348
4209
|
status: body.status,
|
|
4349
4210
|
changeNote: body.changeNote,
|
|
4350
|
-
schedule: body.schedule
|
|
4211
|
+
schedule: body.schedule,
|
|
4212
|
+
prime: body.prime
|
|
4351
4213
|
},
|
|
4352
4214
|
authorEmail
|
|
4353
4215
|
);
|
|
@@ -4356,84 +4218,307 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
4356
4218
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
4357
4219
|
const nestId = c.req.param("nestId");
|
|
4358
4220
|
const nodeId = c.req.param("nodeId");
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
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]
|
|
4363
4440
|
);
|
|
4364
4441
|
}
|
|
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;
|
|
4365
4451
|
try {
|
|
4366
|
-
await storage.
|
|
4452
|
+
const node = await storage.readDocument(nodeId);
|
|
4453
|
+
title = node.frontmatter?.title || nodeId;
|
|
4454
|
+
type = node.frontmatter?.type || null;
|
|
4455
|
+
liveHtml = node.body || "";
|
|
4367
4456
|
} catch {
|
|
4368
|
-
|
|
4457
|
+
liveExists = false;
|
|
4369
4458
|
}
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
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
|
+
}
|
|
4376
4469
|
}
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
)
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4470
|
+
if (!liveExists) {
|
|
4471
|
+
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
4472
|
+
}
|
|
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) {
|
|
4480
|
+
try {
|
|
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
|
|
4422
4518
|
);
|
|
4423
|
-
}
|
|
4424
|
-
await trackEvent("node.delete", { nestId, nodeId });
|
|
4425
|
-
return c.json({ deleted: true });
|
|
4426
|
-
});
|
|
4427
|
-
async function getUserEmail(c) {
|
|
4428
|
-
const userId = c.get("userId");
|
|
4429
|
-
const db = getDb();
|
|
4430
|
-
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4431
|
-
return user?.email || "anonymous@localhost";
|
|
4519
|
+
}
|
|
4432
4520
|
}
|
|
4433
4521
|
|
|
4434
|
-
// src/annotations/routes.ts
|
|
4435
|
-
import { Hono as Hono9 } from "hono";
|
|
4436
|
-
|
|
4437
4522
|
// src/annotations/types.ts
|
|
4438
4523
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
4439
4524
|
|
|
@@ -6909,7 +6994,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6909
6994
|
},
|
|
6910
6995
|
{
|
|
6911
6996
|
name: "context_create",
|
|
6912
|
-
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.",
|
|
6913
6998
|
inputSchema: {
|
|
6914
6999
|
type: "object",
|
|
6915
7000
|
properties: {
|
|
@@ -6930,7 +7015,7 @@ var TOOL_DEFINITIONS = [
|
|
|
6930
7015
|
scope: { type: "string", description: "Visibility scope" },
|
|
6931
7016
|
folder: {
|
|
6932
7017
|
type: "string",
|
|
6933
|
-
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.'
|
|
6934
7019
|
}
|
|
6935
7020
|
},
|
|
6936
7021
|
required: ["title", "content"]
|
|
@@ -6961,6 +7046,49 @@ var TOOL_DEFINITIONS = [
|
|
|
6961
7046
|
required: ["title"]
|
|
6962
7047
|
}
|
|
6963
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
|
+
},
|
|
6964
7092
|
// ─── Governance Tools ───────────────────────────────────────────────
|
|
6965
7093
|
{
|
|
6966
7094
|
name: "context_stewards",
|
|
@@ -6975,6 +7103,73 @@ var TOOL_DEFINITIONS = [
|
|
|
6975
7103
|
}
|
|
6976
7104
|
}
|
|
6977
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
|
+
},
|
|
6978
7173
|
{
|
|
6979
7174
|
name: "context_review_queue",
|
|
6980
7175
|
description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
|
|
@@ -6988,6 +7183,14 @@ var TOOL_DEFINITIONS = [
|
|
|
6988
7183
|
}
|
|
6989
7184
|
}
|
|
6990
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
|
+
},
|
|
6991
7194
|
{
|
|
6992
7195
|
name: "context_submit_review",
|
|
6993
7196
|
description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
|
|
@@ -7140,6 +7343,24 @@ async function handleToolCall(toolName, args, ctx) {
|
|
|
7140
7343
|
});
|
|
7141
7344
|
}
|
|
7142
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
|
+
}
|
|
7143
7364
|
async function runTool(toolName, args, ctx) {
|
|
7144
7365
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
7145
7366
|
switch (toolName) {
|
|
@@ -7373,19 +7594,29 @@ ${sections}`;
|
|
|
7373
7594
|
if (!await canCreateInNest(nestId, userEmail)) {
|
|
7374
7595
|
return "You don't have permission to create documents in this nest.";
|
|
7375
7596
|
}
|
|
7376
|
-
const
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
}
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
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
|
+
});
|
|
7389
7620
|
}
|
|
7390
7621
|
case "context_update": {
|
|
7391
7622
|
const docs = await storage.discoverDocuments();
|
|
@@ -7409,6 +7640,39 @@ ${sections}`;
|
|
|
7409
7640
|
);
|
|
7410
7641
|
return `Updated node: **${updated.frontmatter.title}**`;
|
|
7411
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
|
+
}
|
|
7412
7676
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
7413
7677
|
case "context_stewards": {
|
|
7414
7678
|
if (args.title) {
|
|
@@ -7435,18 +7699,138 @@ ${list}`;
|
|
|
7435
7699
|
if (allStewards.length === 0) {
|
|
7436
7700
|
return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
|
|
7437
7701
|
}
|
|
7438
|
-
const byScope = {};
|
|
7439
|
-
for (const s of allStewards) {
|
|
7440
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
|
|
7441
|
-
(byScope[key] = byScope[key] || []).push(s);
|
|
7702
|
+
const byScope = {};
|
|
7703
|
+
for (const s of allStewards) {
|
|
7704
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
|
|
7705
|
+
(byScope[key] = byScope[key] || []).push(s);
|
|
7706
|
+
}
|
|
7707
|
+
const sections = Object.entries(byScope).map(
|
|
7708
|
+
([scope, stewards]) => `## ${scope}
|
|
7709
|
+
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
|
|
7710
|
+
).join("\n\n");
|
|
7711
|
+
return `# Data Stewards
|
|
7712
|
+
|
|
7713
|
+
${sections}`;
|
|
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.`;
|
|
7442
7813
|
}
|
|
7443
|
-
|
|
7444
|
-
(
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
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.`;
|
|
7450
7834
|
}
|
|
7451
7835
|
case "context_review_queue": {
|
|
7452
7836
|
const status = args.status || "pending";
|
|
@@ -7466,6 +7850,30 @@ ${sections}`;
|
|
|
7466
7850
|
**${result.total} item(s)**
|
|
7467
7851
|
|
|
7468
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\`.`;
|
|
7469
7877
|
}
|
|
7470
7878
|
case "context_submit_review": {
|
|
7471
7879
|
const docs = await storage.discoverDocuments();
|
|
@@ -7617,8 +8025,8 @@ ${list}`;
|
|
|
7617
8025
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
7618
8026
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
7619
8027
|
try {
|
|
7620
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
7621
|
-
const db = (await import("./client-
|
|
8028
|
+
const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
|
|
8029
|
+
const db = (await import("./client-NFUHPLLM.js")).getDb();
|
|
7622
8030
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
7623
8031
|
const e = normalizeEmail2(String(args.email || ""));
|
|
7624
8032
|
if (!e) return "email is required.";
|
|
@@ -8797,6 +9205,7 @@ import { Hono as Hono19 } from "hono";
|
|
|
8797
9205
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8798
9206
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
8799
9207
|
import { z as z2 } from "zod";
|
|
9208
|
+
var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
|
|
8800
9209
|
async function getUserEmail3(userId) {
|
|
8801
9210
|
const db = getDb();
|
|
8802
9211
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
@@ -8978,6 +9387,38 @@ var SERVER_TOOL_DEFINITIONS = [
|
|
|
8978
9387
|
},
|
|
8979
9388
|
required: ["nest", "selector"]
|
|
8980
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
|
+
}
|
|
8981
9422
|
}
|
|
8982
9423
|
];
|
|
8983
9424
|
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
@@ -8986,6 +9427,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
|
|
|
8986
9427
|
if (!nests.length) return "No nests accessible with these credentials.";
|
|
8987
9428
|
return renderNestIndexMarkdown(nests, baseUrl);
|
|
8988
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
|
+
}
|
|
8989
9455
|
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
8990
9456
|
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
8991
9457
|
if (!nest) return inaccessible;
|
|
@@ -9144,7 +9610,9 @@ Streamable HTTP, stateless. Two endpoints:
|
|
|
9144
9610
|
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
9145
9611
|
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
9146
9612
|
each takes a \`nest\` argument (id or exact name) plus the same
|
|
9147
|
-
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).
|
|
9148
9616
|
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
9149
9617
|
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
9150
9618
|
|
|
@@ -9737,6 +10205,123 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
9737
10205
|
}
|
|
9738
10206
|
return c.json({ ...result, requests });
|
|
9739
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
|
+
});
|
|
9740
10325
|
governanceRoutes.get("/feed", async (c) => {
|
|
9741
10326
|
const nestId = c.req.param("nestId");
|
|
9742
10327
|
const filterRaw = c.req.query("filter");
|
|
@@ -9798,25 +10383,71 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
9798
10383
|
governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
9799
10384
|
const nestId = c.req.param("nestId");
|
|
9800
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
|
+
}
|
|
9801
10394
|
const allVersions = await getVersions(nestId, nodeId);
|
|
9802
10395
|
const approved = await getApprovedVersion(nestId, nodeId);
|
|
9803
10396
|
const { versions: versionManager } = await engineCache.get(nestId);
|
|
9804
|
-
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(
|
|
9805
10413
|
allVersions.map(async (v) => {
|
|
10414
|
+
const r = byVersion.get(v.version);
|
|
10415
|
+
const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
|
|
9806
10416
|
try {
|
|
9807
|
-
const
|
|
9808
|
-
return { ...v,
|
|
10417
|
+
const diff = await versionManager.getDiff(nodeId, v.version);
|
|
10418
|
+
return { ...v, ...resolution, diff: diff ?? void 0 };
|
|
9809
10419
|
} catch {
|
|
9810
|
-
return v;
|
|
10420
|
+
return { ...v, ...resolution };
|
|
9811
10421
|
}
|
|
9812
10422
|
})
|
|
9813
10423
|
);
|
|
9814
10424
|
return c.json({
|
|
9815
|
-
versions:
|
|
10425
|
+
versions: enriched,
|
|
9816
10426
|
approvedVersion: approved,
|
|
9817
10427
|
currentVersion: allVersions[0]?.version || 0
|
|
9818
10428
|
});
|
|
9819
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
|
+
});
|
|
9820
10451
|
governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
9821
10452
|
const nestId = c.req.param("nestId");
|
|
9822
10453
|
const nodeId = c.req.param("nodeId");
|
|
@@ -9929,6 +10560,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
9929
10560
|
201
|
|
9930
10561
|
);
|
|
9931
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
|
+
});
|
|
9932
10590
|
governanceNodeRoutes.post(
|
|
9933
10591
|
"/:nodeId{.+}/external-edits/:suggestionId/approve",
|
|
9934
10592
|
async (c) => {
|
|
@@ -10162,6 +10820,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
10162
10820
|
}
|
|
10163
10821
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
10164
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
|
+
);
|
|
10165
10866
|
var APPROVE_CACHE_TTL_MS = 3e4;
|
|
10166
10867
|
var approveCache = /* @__PURE__ */ new Map();
|
|
10167
10868
|
async function canApproveInNest(nestId, email) {
|
|
@@ -10837,6 +11538,25 @@ function createApp() {
|
|
|
10837
11538
|
requested_at: r.requestedAt,
|
|
10838
11539
|
priority: r.priority
|
|
10839
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
|
+
});
|
|
10840
11560
|
const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
|
|
10841
11561
|
const others = requests.filter((r) => r.requestedBy !== me);
|
|
10842
11562
|
const nestApprove = new Map(
|
|
@@ -10849,8 +11569,27 @@ function createApp() {
|
|
|
10849
11569
|
)
|
|
10850
11570
|
)
|
|
10851
11571
|
);
|
|
10852
|
-
const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem)
|
|
10853
|
-
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);
|
|
10854
11593
|
const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
|
|
10855
11594
|
return c.json({
|
|
10856
11595
|
items,
|
|
@@ -10861,6 +11600,37 @@ function createApp() {
|
|
|
10861
11600
|
pagination: { total, limit, offset, has_more: offset + requests.length < total }
|
|
10862
11601
|
});
|
|
10863
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
|
+
});
|
|
10864
11634
|
app.get("/me/notifications", async (c) => {
|
|
10865
11635
|
const me = await resolveCallerEmail(c.get("userId"));
|
|
10866
11636
|
const unreadOnly = c.req.query("unread") === "true";
|
|
@@ -10900,15 +11670,18 @@ function createApp() {
|
|
|
10900
11670
|
app.route("/runs", runDetailRoutes);
|
|
10901
11671
|
app.route("/hooks", hookFireRoutes);
|
|
10902
11672
|
app.route("/llms.txt", llmsTxtRoutes);
|
|
10903
|
-
app.use("/index",
|
|
11673
|
+
app.use("/index", flexAuthWithPromptowlTicket);
|
|
10904
11674
|
app.route("/index", nestIndexRoutes);
|
|
10905
|
-
app.use("/mcp",
|
|
11675
|
+
app.use("/mcp", flexAuthWithPromptowlTicket);
|
|
10906
11676
|
app.route("/mcp", serverMcpRoutes);
|
|
10907
11677
|
const nestsApp = new Hono21();
|
|
10908
|
-
nestsApp.use("*", flexAuthMiddleware);
|
|
10909
11678
|
nestsApp.use("*", async (c, next) => {
|
|
10910
|
-
const
|
|
10911
|
-
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);
|
|
10912
11685
|
if (parts.length < 2) return next();
|
|
10913
11686
|
if (parts[0] === "unsynced") return next();
|
|
10914
11687
|
const nestId = parts[0];
|
|
@@ -10945,7 +11718,8 @@ function createApp() {
|
|
|
10945
11718
|
const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
|
|
10946
11719
|
const isActivityPath = /\/activity$/.test(path2);
|
|
10947
11720
|
const isFeedPath = parts.length === 2 && parts[1] === "feed";
|
|
10948
|
-
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;
|
|
10949
11723
|
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
10950
11724
|
if (needsLicense) {
|
|
10951
11725
|
const lic = getCurrentLicense();
|
|
@@ -10991,6 +11765,7 @@ function createApp() {
|
|
|
10991
11765
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
10992
11766
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
10993
11767
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
11768
|
+
const isDeletionRequestAction = /\/request-deletion$/.test(path);
|
|
10994
11769
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
10995
11770
|
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
10996
11771
|
return c.json(
|
|
@@ -11009,7 +11784,7 @@ function createApp() {
|
|
|
11009
11784
|
required = c.req.method === "GET" ? "write" : "admin";
|
|
11010
11785
|
} else if (parts[1] === "hooks") {
|
|
11011
11786
|
required = "write";
|
|
11012
|
-
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
11787
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
|
|
11013
11788
|
required = "write";
|
|
11014
11789
|
}
|
|
11015
11790
|
const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
|
|
@@ -11289,6 +12064,111 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
11289
12064
|
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
11290
12065
|
);
|
|
11291
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
|
+
}
|
|
11292
12172
|
|
|
11293
12173
|
// src/workflow/scheduler.ts
|
|
11294
12174
|
import { v4 as uuid15 } from "uuid";
|
|
@@ -11366,6 +12246,16 @@ async function main() {
|
|
|
11366
12246
|
} catch (err) {
|
|
11367
12247
|
console.error("[backfill] node_versions backfill failed:", err);
|
|
11368
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
|
+
}
|
|
11369
12259
|
const accessCfg = loadAccessConfig();
|
|
11370
12260
|
if (accessCfg) {
|
|
11371
12261
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|