@promptowl/contextnest-community 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-XDCW4HTW.js → chunk-5VHKEIAW.js} +100 -4
- package/dist/{chunk-BLOPZDPL.js → chunk-JMZ75ZCD.js} +22 -7
- package/dist/{chunk-2FXVMVZJ.js → chunk-K22GWPT4.js} +21 -63
- package/dist/{chunk-2TW25QEA.js → chunk-KQCWNHDM.js} +143 -22
- package/dist/index.js +757 -169
- package/dist/{review-service-2JHZHZWJ.js → review-service-4WS3XL6K.js} +4 -3
- package/dist/{stewardship-service-ZJATH6OM.js → stewardship-service-C5D2O7ZE.js} +2 -2
- package/dist/{version-service-2MZJGE3H.js → version-service-TFEYNPH7.js} +8 -4
- package/dist/web3/assets/index-DkLevP7k.js +624 -0
- package/dist/web3/assets/index-DpoBdKrd.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +11 -2
- package/dist/web3/assets/index-BlGzOlFt.css +0 -1
- package/dist/web3/assets/index-C3W5d7fT.js +0 -591
package/dist/index.js
CHANGED
|
@@ -7,24 +7,17 @@ import {
|
|
|
7
7
|
parseBearerToken,
|
|
8
8
|
verifyPassword
|
|
9
9
|
} from "./chunk-7K2LLJXK.js";
|
|
10
|
-
import {
|
|
11
|
-
checkConflict,
|
|
12
|
-
createVersion,
|
|
13
|
-
getApprovedVersion,
|
|
14
|
-
getCurrentVersion,
|
|
15
|
-
getDisplayStatus,
|
|
16
|
-
getVersions,
|
|
17
|
-
setApprovedVersion
|
|
18
|
-
} from "./chunk-BLOPZDPL.js";
|
|
19
10
|
import {
|
|
20
11
|
approve,
|
|
21
12
|
cancelReview,
|
|
13
|
+
engineCache,
|
|
22
14
|
getPendingReview,
|
|
23
15
|
getReviewHistory,
|
|
24
16
|
getReviewQueue,
|
|
25
17
|
reject,
|
|
18
|
+
safePublishDocument,
|
|
26
19
|
submitForReview
|
|
27
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-5VHKEIAW.js";
|
|
28
21
|
import {
|
|
29
22
|
assignSteward,
|
|
30
23
|
canUserAccess,
|
|
@@ -39,11 +32,22 @@ import {
|
|
|
39
32
|
resolveStewardsForNode,
|
|
40
33
|
resolveStewardsWithFallback,
|
|
41
34
|
syncFromConfig
|
|
42
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-K22GWPT4.js";
|
|
36
|
+
import {
|
|
37
|
+
checkConflict,
|
|
38
|
+
createVersion,
|
|
39
|
+
getApprovedVersion,
|
|
40
|
+
getCurrentVersion,
|
|
41
|
+
getDisplayStatus,
|
|
42
|
+
getVersions,
|
|
43
|
+
setApprovedVersion
|
|
44
|
+
} from "./chunk-JMZ75ZCD.js";
|
|
43
45
|
import {
|
|
46
|
+
ANON_EMAIL,
|
|
47
|
+
ANON_USER_ID,
|
|
44
48
|
config,
|
|
45
49
|
getDb
|
|
46
|
-
} from "./chunk-
|
|
50
|
+
} from "./chunk-KQCWNHDM.js";
|
|
47
51
|
|
|
48
52
|
// src/index.ts
|
|
49
53
|
import { serve } from "@hono/node-server";
|
|
@@ -183,6 +187,12 @@ var NotFoundError = class extends AppError {
|
|
|
183
187
|
this.name = "NotFoundError";
|
|
184
188
|
}
|
|
185
189
|
};
|
|
190
|
+
var ForbiddenError = class extends AppError {
|
|
191
|
+
constructor(message = "Forbidden") {
|
|
192
|
+
super(403, message);
|
|
193
|
+
this.name = "ForbiddenError";
|
|
194
|
+
}
|
|
195
|
+
};
|
|
186
196
|
var ValidationError = class extends AppError {
|
|
187
197
|
constructor(message) {
|
|
188
198
|
super(400, message);
|
|
@@ -333,6 +343,8 @@ async function runLicenseWatcher() {
|
|
|
333
343
|
try {
|
|
334
344
|
watcherAbort = new AbortController();
|
|
335
345
|
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
346
|
+
const fetchTimeout = AbortSignal.timeout(30 * 1e3);
|
|
347
|
+
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
|
|
336
348
|
const res = await fetch(`${promptowlUrl}/api/license/listen`, {
|
|
337
349
|
method: "POST",
|
|
338
350
|
headers: { "Content-Type": "application/json" },
|
|
@@ -340,9 +352,13 @@ async function runLicenseWatcher() {
|
|
|
340
352
|
key,
|
|
341
353
|
since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
342
354
|
}),
|
|
343
|
-
signal
|
|
355
|
+
signal
|
|
344
356
|
});
|
|
345
357
|
if (!res.ok) {
|
|
358
|
+
if (res.status === 504 || res.status === 408 || res.status === 502) {
|
|
359
|
+
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
346
362
|
throw new Error(`listen returned ${res.status}`);
|
|
347
363
|
}
|
|
348
364
|
const data = await res.json();
|
|
@@ -375,6 +391,30 @@ async function runLicenseWatcher() {
|
|
|
375
391
|
function sleep(ms) {
|
|
376
392
|
return new Promise((r) => setTimeout(r, ms));
|
|
377
393
|
}
|
|
394
|
+
var safetyPollHandle = null;
|
|
395
|
+
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
396
|
+
function startLicenseSafetyPoll() {
|
|
397
|
+
if (safetyPollHandle) return;
|
|
398
|
+
safetyPollHandle = setInterval(async () => {
|
|
399
|
+
if (!config.PROMPTOWL_KEY) return;
|
|
400
|
+
try {
|
|
401
|
+
const wasValid = !!currentLicense?.valid;
|
|
402
|
+
await validateLicense({ forceFresh: true });
|
|
403
|
+
const isValid = !!currentLicense?.valid;
|
|
404
|
+
if (wasValid && !isValid) {
|
|
405
|
+
console.log("[license] safety poll detected revocation");
|
|
406
|
+
handleLicenseRevoked();
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`[license] safety poll error: ${err.message}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}, SAFETY_POLL_INTERVAL_MS);
|
|
414
|
+
if (typeof safetyPollHandle.unref === "function") {
|
|
415
|
+
safetyPollHandle.unref();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
378
418
|
function handleLicenseRevoked() {
|
|
379
419
|
try {
|
|
380
420
|
const db = getDb();
|
|
@@ -1030,9 +1070,9 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1030
1070
|
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
|
|
1031
1071
|
(SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
|
|
1032
1072
|
FROM users u
|
|
1033
|
-
WHERE u.id !=
|
|
1073
|
+
WHERE u.id != ?
|
|
1034
1074
|
ORDER BY u.created_at DESC`
|
|
1035
|
-
).all();
|
|
1075
|
+
).all(ANON_USER_ID);
|
|
1036
1076
|
const pendingStewards = db.prepare(
|
|
1037
1077
|
`SELECT DISTINCT s.user_email AS email
|
|
1038
1078
|
FROM stewards s
|
|
@@ -1067,6 +1107,9 @@ function resolveNestPermission(nestId, userId) {
|
|
|
1067
1107
|
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
1068
1108
|
if (!nest) return "none";
|
|
1069
1109
|
if (nest.user_id === userId) return "owner";
|
|
1110
|
+
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
1111
|
+
return "owner";
|
|
1112
|
+
}
|
|
1070
1113
|
const directGrant = db.prepare(
|
|
1071
1114
|
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
1072
1115
|
).get(nestId, userId);
|
|
@@ -1105,9 +1148,10 @@ async function createNest(userId, name, description) {
|
|
|
1105
1148
|
const id = uuid2();
|
|
1106
1149
|
const slug = toSlug(name);
|
|
1107
1150
|
const db = getDb();
|
|
1151
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1108
1152
|
db.prepare(
|
|
1109
|
-
"INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
|
|
1110
|
-
).run(id, userId, name, slug, description || null);
|
|
1153
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1154
|
+
).run(id, userId, name, slug, description || null, visibility);
|
|
1111
1155
|
const path = nestPath(id);
|
|
1112
1156
|
mkdirSync(path, { recursive: true });
|
|
1113
1157
|
const storage = new NestStorage(path);
|
|
@@ -1115,14 +1159,17 @@ async function createNest(userId, name, description) {
|
|
|
1115
1159
|
trackEvent("nest.create", { nestId: id, userId });
|
|
1116
1160
|
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
1117
1161
|
}
|
|
1118
|
-
var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
1119
1162
|
function listNests(userId) {
|
|
1120
1163
|
const db = getDb();
|
|
1121
1164
|
if (userId === ANON_USER_ID) {
|
|
1122
1165
|
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
|
|
1123
1166
|
}
|
|
1167
|
+
const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
|
|
1168
|
+
if (!includeAnon) {
|
|
1169
|
+
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
|
|
1170
|
+
}
|
|
1124
1171
|
return db.prepare(
|
|
1125
|
-
"SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
|
|
1172
|
+
"SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
|
|
1126
1173
|
).all(userId, ANON_USER_ID);
|
|
1127
1174
|
}
|
|
1128
1175
|
function listSharedNests(userId) {
|
|
@@ -1140,23 +1187,35 @@ function getNest(nestId) {
|
|
|
1140
1187
|
}
|
|
1141
1188
|
async function deleteNest(nestId) {
|
|
1142
1189
|
const db = getDb();
|
|
1143
|
-
|
|
1144
|
-
|
|
1190
|
+
const wipe = db.transaction((id) => {
|
|
1191
|
+
db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
|
|
1192
|
+
db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
|
|
1193
|
+
db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
|
|
1194
|
+
db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
|
|
1195
|
+
db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
|
|
1196
|
+
try {
|
|
1197
|
+
db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
|
|
1198
|
+
} catch {
|
|
1199
|
+
}
|
|
1200
|
+
db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
|
|
1201
|
+
db.prepare("DELETE FROM nests WHERE id = ?").run(id);
|
|
1202
|
+
});
|
|
1203
|
+
wipe(nestId);
|
|
1145
1204
|
const path = nestPath(nestId);
|
|
1146
1205
|
try {
|
|
1147
1206
|
rmSync(path, { recursive: true, force: true });
|
|
1148
|
-
} catch {
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
1149
1209
|
}
|
|
1150
1210
|
trackEvent("nest.delete", { nestId });
|
|
1151
1211
|
}
|
|
1152
1212
|
|
|
1153
1213
|
// src/nests/routes.ts
|
|
1154
|
-
var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
|
|
1155
1214
|
function effectivePermission(nestId, userId) {
|
|
1156
1215
|
if (config.AUTH_MODE === "open") {
|
|
1157
1216
|
const db = getDb();
|
|
1158
1217
|
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1159
|
-
if (nest && nest.user_id ===
|
|
1218
|
+
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1160
1219
|
}
|
|
1161
1220
|
return resolveNestPermission(nestId, userId);
|
|
1162
1221
|
}
|
|
@@ -1165,7 +1224,23 @@ nestRoutes.get("/", async (c) => {
|
|
|
1165
1224
|
const userId = c.get("userId");
|
|
1166
1225
|
const owned = listNests(userId);
|
|
1167
1226
|
const shared = listSharedNests(userId);
|
|
1168
|
-
|
|
1227
|
+
const db = getDb();
|
|
1228
|
+
const ownerEmailStmt = db.prepare(
|
|
1229
|
+
"SELECT email FROM users WHERE id = ?"
|
|
1230
|
+
);
|
|
1231
|
+
const annotate = (n) => {
|
|
1232
|
+
const permission = effectivePermission(n.id, userId);
|
|
1233
|
+
const is_owner = permission === "owner";
|
|
1234
|
+
let owner_email = null;
|
|
1235
|
+
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1236
|
+
const row = ownerEmailStmt.get(n.user_id);
|
|
1237
|
+
owner_email = row?.email ?? null;
|
|
1238
|
+
}
|
|
1239
|
+
return { ...n, permission, is_owner, owner_email };
|
|
1240
|
+
};
|
|
1241
|
+
return c.json({
|
|
1242
|
+
nests: [...owned.map(annotate), ...shared.map(annotate)]
|
|
1243
|
+
});
|
|
1169
1244
|
});
|
|
1170
1245
|
nestRoutes.post("/", async (c) => {
|
|
1171
1246
|
const body = await c.req.json();
|
|
@@ -1186,10 +1261,19 @@ nestRoutes.get("/:nestId", async (c) => {
|
|
|
1186
1261
|
});
|
|
1187
1262
|
nestRoutes.delete("/:nestId", async (c) => {
|
|
1188
1263
|
const nestId = c.req.param("nestId");
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1264
|
+
const userId = c.get("userId");
|
|
1265
|
+
const nest = getNest(nestId);
|
|
1266
|
+
if (!nest) {
|
|
1191
1267
|
throw new NotFoundError("Nest not found");
|
|
1192
1268
|
}
|
|
1269
|
+
const permission = effectivePermission(nestId, userId);
|
|
1270
|
+
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1271
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1272
|
+
if (permission !== "owner" && !adminCaretaker) {
|
|
1273
|
+
throw new ForbiddenError(
|
|
1274
|
+
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1193
1277
|
await deleteNest(nestId);
|
|
1194
1278
|
return c.json({ deleted: true });
|
|
1195
1279
|
});
|
|
@@ -1306,37 +1390,26 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
1306
1390
|
return c.json({ visibility: body.visibility });
|
|
1307
1391
|
});
|
|
1308
1392
|
|
|
1393
|
+
// src/governance/access-guard.ts
|
|
1394
|
+
function resolveCallerEmail(userId) {
|
|
1395
|
+
if (!userId) return "admin@localhost";
|
|
1396
|
+
const db = getDb();
|
|
1397
|
+
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1398
|
+
return row?.email || "admin@localhost";
|
|
1399
|
+
}
|
|
1400
|
+
function canReadNode(nestId, nodeId, userEmail) {
|
|
1401
|
+
if (!isStewardshipEnabled(nestId)) return true;
|
|
1402
|
+
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1403
|
+
}
|
|
1404
|
+
function filterAccessible(nestId, userEmail, nodes) {
|
|
1405
|
+
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1406
|
+
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1309
1409
|
// src/nodes/routes.ts
|
|
1310
1410
|
import { Hono as Hono4 } from "hono";
|
|
1311
1411
|
import { serializeDocument } from "@promptowl/contextnest-engine";
|
|
1312
1412
|
|
|
1313
|
-
// src/nodes/engine.ts
|
|
1314
|
-
import { join as join2 } from "path";
|
|
1315
|
-
import {
|
|
1316
|
-
NestStorage as NestStorage2,
|
|
1317
|
-
GraphQueryEngine,
|
|
1318
|
-
VersionManager
|
|
1319
|
-
} from "@promptowl/contextnest-engine";
|
|
1320
|
-
var NestEngineCache = class {
|
|
1321
|
-
cache = /* @__PURE__ */ new Map();
|
|
1322
|
-
get(nestId) {
|
|
1323
|
-
let engine = this.cache.get(nestId);
|
|
1324
|
-
if (!engine) {
|
|
1325
|
-
const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
|
|
1326
|
-
const storage = new NestStorage2(nestPath2);
|
|
1327
|
-
const query = new GraphQueryEngine(storage);
|
|
1328
|
-
const versions = new VersionManager(storage);
|
|
1329
|
-
engine = { storage, query, versions };
|
|
1330
|
-
this.cache.set(nestId, engine);
|
|
1331
|
-
}
|
|
1332
|
-
return engine;
|
|
1333
|
-
}
|
|
1334
|
-
evict(nestId) {
|
|
1335
|
-
this.cache.delete(nestId);
|
|
1336
|
-
}
|
|
1337
|
-
};
|
|
1338
|
-
var engineCache = new NestEngineCache();
|
|
1339
|
-
|
|
1340
1413
|
// src/governance/tag-index-service.ts
|
|
1341
1414
|
function normalizeTag(raw) {
|
|
1342
1415
|
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
@@ -1367,20 +1440,220 @@ function removeNodeFromTagIndex(nestId, nodeId) {
|
|
|
1367
1440
|
).run(nestId, nodeId);
|
|
1368
1441
|
}
|
|
1369
1442
|
|
|
1370
|
-
// src/governance/
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1443
|
+
// src/governance/external-edit-service.ts
|
|
1444
|
+
import { readFile } from "fs/promises";
|
|
1445
|
+
import { join as join2 } from "path";
|
|
1446
|
+
import {
|
|
1447
|
+
detectDrift,
|
|
1448
|
+
stageSuggestion,
|
|
1449
|
+
approveSuggestion,
|
|
1450
|
+
rejectSuggestion,
|
|
1451
|
+
listSuggestions,
|
|
1452
|
+
readSuggestion,
|
|
1453
|
+
VersionManager
|
|
1454
|
+
} from "@promptowl/contextnest-engine";
|
|
1455
|
+
var communityRbac = {
|
|
1456
|
+
isCzar: () => false,
|
|
1457
|
+
canIngest: () => true,
|
|
1458
|
+
isDocOwner: () => true
|
|
1459
|
+
};
|
|
1460
|
+
function docPath(nestId, documentId) {
|
|
1461
|
+
return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
1376
1462
|
}
|
|
1377
|
-
function
|
|
1378
|
-
|
|
1379
|
-
|
|
1463
|
+
async function readRaw(nestId, documentId) {
|
|
1464
|
+
try {
|
|
1465
|
+
return await readFile(docPath(nestId, documentId), "utf-8");
|
|
1466
|
+
} catch {
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1380
1469
|
}
|
|
1381
|
-
function
|
|
1382
|
-
|
|
1383
|
-
|
|
1470
|
+
async function loadChainHead(storage, documentId) {
|
|
1471
|
+
const history = await storage.readHistory(documentId);
|
|
1472
|
+
if (!history || history.versions.length === 0) return null;
|
|
1473
|
+
const latest = history.versions[history.versions.length - 1];
|
|
1474
|
+
try {
|
|
1475
|
+
const content = await new VersionManager(storage).reconstructVersion(
|
|
1476
|
+
documentId,
|
|
1477
|
+
latest.version
|
|
1478
|
+
);
|
|
1479
|
+
return { version: latest.version, content };
|
|
1480
|
+
} catch {
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
1485
|
+
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
1486
|
+
return res?.meta ?? null;
|
|
1487
|
+
}
|
|
1488
|
+
async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
1489
|
+
const { storage } = engineCache.get(nestId);
|
|
1490
|
+
const node = await storage.readDocument(documentId).catch(() => null);
|
|
1491
|
+
if (!node) return null;
|
|
1492
|
+
const raw = await readRaw(nestId, documentId);
|
|
1493
|
+
if (raw == null) return null;
|
|
1494
|
+
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
1495
|
+
if (!drift.drifted) return null;
|
|
1496
|
+
const approved = await loadChainHead(storage, documentId);
|
|
1497
|
+
if (!approved) return null;
|
|
1498
|
+
const existing = await listSuggestions(storage, documentId);
|
|
1499
|
+
const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
|
|
1500
|
+
if (dup) return { meta: dup, created: false };
|
|
1501
|
+
const result = await stageSuggestion({
|
|
1502
|
+
storage,
|
|
1503
|
+
documentId,
|
|
1504
|
+
approvedRawContent: approved.content,
|
|
1505
|
+
proposedRawContent: raw,
|
|
1506
|
+
source: "out-of-band-edit",
|
|
1507
|
+
actor,
|
|
1508
|
+
docTier: "standard"
|
|
1509
|
+
});
|
|
1510
|
+
return { meta: result.meta, created: true };
|
|
1511
|
+
}
|
|
1512
|
+
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
1513
|
+
const { storage } = engineCache.get(nestId);
|
|
1514
|
+
const docs = await storage.discoverDocuments();
|
|
1515
|
+
const results = await Promise.all(
|
|
1516
|
+
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
1517
|
+
);
|
|
1518
|
+
const staged = results.filter((r) => r?.created).length;
|
|
1519
|
+
return { scanned: docs.length, staged };
|
|
1520
|
+
}
|
|
1521
|
+
async function getPendingChange(nestId, documentId) {
|
|
1522
|
+
const { storage } = engineCache.get(nestId);
|
|
1523
|
+
const list = await listSuggestions(storage, documentId);
|
|
1524
|
+
if (list.length === 0) return null;
|
|
1525
|
+
const latest = list[list.length - 1];
|
|
1526
|
+
return {
|
|
1527
|
+
suggestion_id: latest.suggestion_id,
|
|
1528
|
+
detected_at: latest.detected_at,
|
|
1529
|
+
source: latest.source,
|
|
1530
|
+
proposed_hash: latest.proposed_hash
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
async function listNestExternalEdits(nestId) {
|
|
1534
|
+
const { storage } = engineCache.get(nestId);
|
|
1535
|
+
const docs = await storage.discoverDocuments();
|
|
1536
|
+
const lists = await Promise.all(
|
|
1537
|
+
docs.map((doc) => listSuggestions(storage, doc.id))
|
|
1538
|
+
);
|
|
1539
|
+
const entries = lists.flat().map((meta) => ({
|
|
1540
|
+
suggestion_id: meta.suggestion_id,
|
|
1541
|
+
nest_id: nestId,
|
|
1542
|
+
document_id: meta.document_id,
|
|
1543
|
+
source: meta.source,
|
|
1544
|
+
detected_at: meta.detected_at,
|
|
1545
|
+
actor: meta.actor,
|
|
1546
|
+
target_hash: meta.target_hash,
|
|
1547
|
+
proposed_hash: meta.proposed_hash,
|
|
1548
|
+
note: meta.note
|
|
1549
|
+
}));
|
|
1550
|
+
return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
|
|
1551
|
+
}
|
|
1552
|
+
async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
1553
|
+
const { storage } = engineCache.get(nestId);
|
|
1554
|
+
const found = await readSuggestion(storage, documentId, suggestionId);
|
|
1555
|
+
if (!found) return null;
|
|
1556
|
+
return {
|
|
1557
|
+
suggestion_id: found.meta.suggestion_id,
|
|
1558
|
+
nest_id: nestId,
|
|
1559
|
+
document_id: found.meta.document_id,
|
|
1560
|
+
source: found.meta.source,
|
|
1561
|
+
detected_at: found.meta.detected_at,
|
|
1562
|
+
actor: found.meta.actor,
|
|
1563
|
+
target_hash: found.meta.target_hash,
|
|
1564
|
+
proposed_hash: found.meta.proposed_hash,
|
|
1565
|
+
note: found.meta.note,
|
|
1566
|
+
patch: found.patch
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
async function approveExternalEdit(input) {
|
|
1570
|
+
const { storage } = engineCache.get(input.nestId);
|
|
1571
|
+
let result;
|
|
1572
|
+
try {
|
|
1573
|
+
result = await approveSuggestion({
|
|
1574
|
+
storage,
|
|
1575
|
+
rbac: communityRbac,
|
|
1576
|
+
documentId: input.documentId,
|
|
1577
|
+
suggestionId: input.suggestionId,
|
|
1578
|
+
actor: input.actor,
|
|
1579
|
+
zone: "default",
|
|
1580
|
+
comment: input.comment
|
|
1581
|
+
});
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
console.error(
|
|
1584
|
+
`[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
|
|
1585
|
+
err
|
|
1586
|
+
);
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const node = await storage.readDocument(input.documentId);
|
|
1591
|
+
const versionNum = result.versionEntry.version;
|
|
1592
|
+
const tags = node.frontmatter.tags || [];
|
|
1593
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
|
|
1594
|
+
createVersion2({
|
|
1595
|
+
nestId: input.nestId,
|
|
1596
|
+
nodeId: input.documentId,
|
|
1597
|
+
version: versionNum,
|
|
1598
|
+
content: node.body || "",
|
|
1599
|
+
author: input.actor,
|
|
1600
|
+
status: "published",
|
|
1601
|
+
tags,
|
|
1602
|
+
changeNote: input.comment || "External edit approved"
|
|
1603
|
+
});
|
|
1604
|
+
setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
|
|
1605
|
+
} catch (err) {
|
|
1606
|
+
console.error(
|
|
1607
|
+
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
1608
|
+
err
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
return result;
|
|
1612
|
+
}
|
|
1613
|
+
async function rejectExternalEdit(input) {
|
|
1614
|
+
const { storage } = engineCache.get(input.nestId);
|
|
1615
|
+
const result = await rejectSuggestion({
|
|
1616
|
+
storage,
|
|
1617
|
+
rbac: communityRbac,
|
|
1618
|
+
documentId: input.documentId,
|
|
1619
|
+
suggestionId: input.suggestionId,
|
|
1620
|
+
actor: input.actor,
|
|
1621
|
+
zone: "default",
|
|
1622
|
+
reason: input.reason
|
|
1623
|
+
});
|
|
1624
|
+
try {
|
|
1625
|
+
const approved = await loadChainHead(storage, input.documentId);
|
|
1626
|
+
if (approved) {
|
|
1627
|
+
await storage.writeDocument(input.documentId, approved.content);
|
|
1628
|
+
}
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
console.error(
|
|
1631
|
+
`[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
|
|
1632
|
+
err
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
return result;
|
|
1636
|
+
}
|
|
1637
|
+
var scannerTimer = null;
|
|
1638
|
+
async function scanAllNests() {
|
|
1639
|
+
const db = getDb();
|
|
1640
|
+
const rows = db.prepare("SELECT id FROM nests").all();
|
|
1641
|
+
await Promise.all(
|
|
1642
|
+
rows.map(
|
|
1643
|
+
({ id }) => scanNestForDrift(id).catch(
|
|
1644
|
+
(err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
|
|
1645
|
+
)
|
|
1646
|
+
)
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
function startDriftScanner(intervalMs = 3e4) {
|
|
1650
|
+
if (scannerTimer) return;
|
|
1651
|
+
scannerTimer = setInterval(() => {
|
|
1652
|
+
scanAllNests().catch(
|
|
1653
|
+
(err) => console.error("[external-edit] scanner tick failed:", err)
|
|
1654
|
+
);
|
|
1655
|
+
}, intervalMs);
|
|
1656
|
+
scannerTimer.unref?.();
|
|
1384
1657
|
}
|
|
1385
1658
|
|
|
1386
1659
|
// src/nodes/routes.ts
|
|
@@ -1400,7 +1673,8 @@ function toNodeResponse(node) {
|
|
|
1400
1673
|
description: node.frontmatter.description,
|
|
1401
1674
|
created_at: node.frontmatter.created_at,
|
|
1402
1675
|
updated_at: node.frontmatter.updated_at,
|
|
1403
|
-
content: node.body
|
|
1676
|
+
content: node.body,
|
|
1677
|
+
pendingChange: node.pendingChange ?? void 0
|
|
1404
1678
|
};
|
|
1405
1679
|
}
|
|
1406
1680
|
nodeRoutes.get("/", async (c) => {
|
|
@@ -1409,11 +1683,27 @@ nodeRoutes.get("/", async (c) => {
|
|
|
1409
1683
|
const documents = await storage.discoverDocuments();
|
|
1410
1684
|
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
1411
1685
|
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
1686
|
+
const pendingByDoc = /* @__PURE__ */ new Map();
|
|
1687
|
+
await Promise.all(
|
|
1688
|
+
accessible.map(async (doc) => {
|
|
1689
|
+
try {
|
|
1690
|
+
pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
|
|
1691
|
+
} catch {
|
|
1692
|
+
pendingByDoc.set(doc.id, null);
|
|
1693
|
+
}
|
|
1694
|
+
})
|
|
1695
|
+
);
|
|
1412
1696
|
return c.json({
|
|
1413
1697
|
count: accessible.length,
|
|
1414
1698
|
nodes: accessible.map((doc) => {
|
|
1415
1699
|
const r = toNodeResponse(doc);
|
|
1416
|
-
|
|
1700
|
+
const pending = pendingByDoc.get(doc.id);
|
|
1701
|
+
if (pending) {
|
|
1702
|
+
r.pendingChange = pending;
|
|
1703
|
+
r.status = "external_edit_pending";
|
|
1704
|
+
} else {
|
|
1705
|
+
r.status = getDisplayStatus(nestId, r.id);
|
|
1706
|
+
}
|
|
1417
1707
|
return r;
|
|
1418
1708
|
})
|
|
1419
1709
|
});
|
|
@@ -1431,7 +1721,8 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1431
1721
|
const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
|
|
1432
1722
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1433
1723
|
const initialStatus = hasStewards ? "draft" : "approved";
|
|
1434
|
-
const
|
|
1724
|
+
const initialVersion = hasStewards ? 1 : 0;
|
|
1725
|
+
let node = {
|
|
1435
1726
|
id,
|
|
1436
1727
|
filePath: "",
|
|
1437
1728
|
frontmatter: {
|
|
@@ -1439,7 +1730,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1439
1730
|
type: body.type || "document",
|
|
1440
1731
|
tags,
|
|
1441
1732
|
status: body.status || initialStatus,
|
|
1442
|
-
version:
|
|
1733
|
+
version: initialVersion,
|
|
1443
1734
|
created_at: now,
|
|
1444
1735
|
updated_at: now,
|
|
1445
1736
|
metadata: {
|
|
@@ -1454,22 +1745,51 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1454
1745
|
await storage.writeDocument(id, serialized);
|
|
1455
1746
|
syncNodeTags(nestId, id, tags);
|
|
1456
1747
|
const authorEmail = getUserEmail(c);
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1748
|
+
if (hasStewards) {
|
|
1749
|
+
try {
|
|
1750
|
+
await versionManager.createVersion(node, authorEmail);
|
|
1751
|
+
} catch (err) {
|
|
1752
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1753
|
+
}
|
|
1754
|
+
createVersion({
|
|
1755
|
+
nestId,
|
|
1756
|
+
nodeId: id,
|
|
1757
|
+
version: 1,
|
|
1758
|
+
content: body.content,
|
|
1759
|
+
author: authorEmail,
|
|
1760
|
+
status: "draft",
|
|
1761
|
+
tags
|
|
1762
|
+
});
|
|
1763
|
+
} else {
|
|
1764
|
+
try {
|
|
1765
|
+
const result = await safePublishDocument(storage, id, {
|
|
1766
|
+
editedBy: authorEmail,
|
|
1767
|
+
note: "Auto-published on create (no stewards configured)"
|
|
1768
|
+
});
|
|
1769
|
+
const publishedVersion = result.node.frontmatter.version || 2;
|
|
1770
|
+
createVersion({
|
|
1771
|
+
nestId,
|
|
1772
|
+
nodeId: id,
|
|
1773
|
+
version: publishedVersion,
|
|
1774
|
+
content: result.node.body || "",
|
|
1775
|
+
author: authorEmail,
|
|
1776
|
+
status: "published",
|
|
1777
|
+
tags
|
|
1778
|
+
});
|
|
1779
|
+
setApprovedVersion(nestId, id, publishedVersion, authorEmail);
|
|
1780
|
+
node = result.node;
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1783
|
+
createVersion({
|
|
1784
|
+
nestId,
|
|
1785
|
+
nodeId: id,
|
|
1786
|
+
version: 1,
|
|
1787
|
+
content: body.content,
|
|
1788
|
+
author: authorEmail,
|
|
1789
|
+
status: "draft",
|
|
1790
|
+
tags
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1473
1793
|
}
|
|
1474
1794
|
trackEvent("node.create", { nestId, nodeId: id });
|
|
1475
1795
|
const resolved = resolveStewardsForNode(nestId, id);
|
|
@@ -1485,7 +1805,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1485
1805
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1486
1806
|
const nestId = c.req.param("nestId");
|
|
1487
1807
|
const nodeId = c.req.param("nodeId");
|
|
1488
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
1808
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
|
|
1489
1809
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1490
1810
|
nestId,
|
|
1491
1811
|
nodeId
|
|
@@ -1497,8 +1817,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1497
1817
|
role: r.steward.role,
|
|
1498
1818
|
scope: r.steward.scope,
|
|
1499
1819
|
source: r.source,
|
|
1500
|
-
priority: r.priority
|
|
1501
|
-
canApprove: r.steward.canApprove
|
|
1820
|
+
priority: r.priority
|
|
1502
1821
|
})),
|
|
1503
1822
|
fallbackToOwner,
|
|
1504
1823
|
ownerEmail
|
|
@@ -1507,7 +1826,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1507
1826
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1508
1827
|
const nestId = c.req.param("nestId");
|
|
1509
1828
|
const nodeId = c.req.param("nodeId");
|
|
1510
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
1829
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
|
|
1511
1830
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1512
1831
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1513
1832
|
const db = getDb();
|
|
@@ -1538,7 +1857,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1538
1857
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1539
1858
|
const nestId = c.req.param("nestId");
|
|
1540
1859
|
const nodeId = c.req.param("nodeId");
|
|
1541
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
1860
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
|
|
1542
1861
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1543
1862
|
return c.json({ reviews: history });
|
|
1544
1863
|
});
|
|
@@ -1555,12 +1874,21 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1555
1874
|
}
|
|
1556
1875
|
let node;
|
|
1557
1876
|
try {
|
|
1558
|
-
node = await storage.readDocument(nodeId);
|
|
1877
|
+
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
1559
1878
|
} catch {
|
|
1560
1879
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1561
1880
|
}
|
|
1881
|
+
if (node.pendingChange) {
|
|
1882
|
+
try {
|
|
1883
|
+
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
1884
|
+
const refreshed = await getPendingChange(nestId, nodeId);
|
|
1885
|
+
if (refreshed) node.pendingChange = refreshed;
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
console.error("[external-edit] stage-on-read failed:", err);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1562
1890
|
const response = toNodeResponse(node);
|
|
1563
|
-
response.status = getDisplayStatus(nestId, nodeId);
|
|
1891
|
+
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
1564
1892
|
return c.json({ node: response });
|
|
1565
1893
|
});
|
|
1566
1894
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
@@ -1615,51 +1943,99 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
1615
1943
|
frontmatter: { ...node.frontmatter, title: body.title }
|
|
1616
1944
|
};
|
|
1617
1945
|
}
|
|
1618
|
-
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1619
|
-
const newVersion = currentVersion + 1;
|
|
1620
|
-
node = {
|
|
1621
|
-
...node,
|
|
1622
|
-
frontmatter: {
|
|
1623
|
-
...node.frontmatter,
|
|
1624
|
-
version: newVersion,
|
|
1625
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1626
|
-
}
|
|
1627
|
-
};
|
|
1628
|
-
const fm = Object.fromEntries(
|
|
1629
|
-
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1630
|
-
);
|
|
1631
|
-
node = { ...node, frontmatter: fm };
|
|
1632
|
-
const serialized = serializeDocument(node);
|
|
1633
|
-
await storage.writeDocument(nodeId, serialized);
|
|
1634
1946
|
const authorEmail = getUserEmail(c);
|
|
1635
1947
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1636
1948
|
const currentTags = node.frontmatter.tags || [];
|
|
1637
|
-
|
|
1638
|
-
try {
|
|
1639
|
-
await versionManager.createVersion(node, authorEmail, {
|
|
1640
|
-
note: body.changeNote
|
|
1641
|
-
});
|
|
1642
|
-
} catch (err) {
|
|
1643
|
-
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1644
|
-
}
|
|
1645
|
-
createVersion({
|
|
1646
|
-
nestId,
|
|
1647
|
-
nodeId,
|
|
1648
|
-
version: newVersion,
|
|
1649
|
-
content: node.body || "",
|
|
1650
|
-
author: authorEmail,
|
|
1651
|
-
status: hasStewards ? "draft" : "approved",
|
|
1652
|
-
tags: currentTags,
|
|
1653
|
-
changeNote: body.changeNote
|
|
1654
|
-
});
|
|
1655
|
-
const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-2JHZHZWJ.js");
|
|
1949
|
+
const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-4WS3XL6K.js");
|
|
1656
1950
|
if (getPendingReview2(nestId, nodeId)) {
|
|
1657
1951
|
cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
|
|
1658
1952
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1953
|
+
let responseVersion;
|
|
1954
|
+
if (hasStewards) {
|
|
1955
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1956
|
+
const newVersion = currentVersion + 1;
|
|
1957
|
+
node = {
|
|
1958
|
+
...node,
|
|
1959
|
+
frontmatter: {
|
|
1960
|
+
...node.frontmatter,
|
|
1961
|
+
version: newVersion,
|
|
1962
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1963
|
+
// Strip the stale published-state checksum — the body just
|
|
1964
|
+
// changed, so leaving the old hash would make the next GET (with
|
|
1965
|
+
// verifyChecksum: true) flag the file as an external edit even
|
|
1966
|
+
// though *this* app wrote it. Engine treats absent checksum as
|
|
1967
|
+
// "no baseline" and skips drift detection until publish runs.
|
|
1968
|
+
checksum: void 0
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
const fm = Object.fromEntries(
|
|
1972
|
+
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1973
|
+
);
|
|
1974
|
+
node = { ...node, frontmatter: fm };
|
|
1975
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1976
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1977
|
+
try {
|
|
1978
|
+
await versionManager.createVersion(node, authorEmail, {
|
|
1979
|
+
note: body.changeNote
|
|
1980
|
+
});
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1983
|
+
}
|
|
1984
|
+
createVersion({
|
|
1985
|
+
nestId,
|
|
1986
|
+
nodeId,
|
|
1987
|
+
version: newVersion,
|
|
1988
|
+
content: node.body || "",
|
|
1989
|
+
author: authorEmail,
|
|
1990
|
+
status: "draft",
|
|
1991
|
+
tags: currentTags,
|
|
1992
|
+
changeNote: body.changeNote
|
|
1993
|
+
});
|
|
1994
|
+
responseVersion = newVersion;
|
|
1995
|
+
} else {
|
|
1996
|
+
node = {
|
|
1997
|
+
...node,
|
|
1998
|
+
frontmatter: {
|
|
1999
|
+
...node.frontmatter,
|
|
2000
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2001
|
+
// Drop the prior published-state checksum before the interim
|
|
2002
|
+
// write — publish recomputes it from the new body, but if
|
|
2003
|
+
// publish errors out the file would otherwise be left with new
|
|
2004
|
+
// body + stale checksum and drift on next read.
|
|
2005
|
+
checksum: void 0
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
const fm = Object.fromEntries(
|
|
2009
|
+
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
2010
|
+
);
|
|
2011
|
+
node = { ...node, frontmatter: fm };
|
|
2012
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2013
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
2014
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2015
|
+
try {
|
|
2016
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
2017
|
+
editedBy: authorEmail,
|
|
2018
|
+
note: body.changeNote || "Auto-published on edit (no stewards)"
|
|
2019
|
+
});
|
|
2020
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
2021
|
+
node = result.node;
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
2024
|
+
}
|
|
2025
|
+
createVersion({
|
|
2026
|
+
nestId,
|
|
2027
|
+
nodeId,
|
|
2028
|
+
version: publishedVersion,
|
|
2029
|
+
content: node.body || "",
|
|
2030
|
+
author: authorEmail,
|
|
2031
|
+
status: "published",
|
|
2032
|
+
tags: currentTags,
|
|
2033
|
+
changeNote: body.changeNote
|
|
2034
|
+
});
|
|
2035
|
+
setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
|
|
2036
|
+
responseVersion = publishedVersion;
|
|
1661
2037
|
}
|
|
1662
|
-
return c.json({ node: toNodeResponse(node), version:
|
|
2038
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
1663
2039
|
});
|
|
1664
2040
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
1665
2041
|
const nestId = c.req.param("nestId");
|
|
@@ -2346,13 +2722,13 @@ var TOOL_DEFINITIONS = [
|
|
|
2346
2722
|
},
|
|
2347
2723
|
{
|
|
2348
2724
|
name: "context_assign_steward",
|
|
2349
|
-
description: "Assign a data steward to govern a scope (nest, tag,
|
|
2725
|
+
description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
|
|
2350
2726
|
inputSchema: {
|
|
2351
2727
|
type: "object",
|
|
2352
2728
|
properties: {
|
|
2353
2729
|
email: { type: "string", description: "Email of the person to assign as steward" },
|
|
2354
|
-
scope: { type: "string", description: "Scope: nest (all docs), tag,
|
|
2355
|
-
target: { type: "string", description: "Scope target: tag name (e.g. #architecture)
|
|
2730
|
+
scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
|
|
2731
|
+
target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
|
|
2356
2732
|
role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
|
|
2357
2733
|
},
|
|
2358
2734
|
required: ["email", "scope"]
|
|
@@ -2625,7 +3001,7 @@ ${n.body || ""}`;
|
|
|
2625
3001
|
return `No stewards configured for "${args.title}". Changes are auto-approved.`;
|
|
2626
3002
|
}
|
|
2627
3003
|
const list = resolved.map(
|
|
2628
|
-
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.
|
|
3004
|
+
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
|
|
2629
3005
|
).join("\n");
|
|
2630
3006
|
return `# Stewards for "${args.title}"
|
|
2631
3007
|
|
|
@@ -2637,12 +3013,12 @@ ${list}`;
|
|
|
2637
3013
|
}
|
|
2638
3014
|
const byScope = {};
|
|
2639
3015
|
for (const s of allStewards) {
|
|
2640
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` :
|
|
3016
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
|
|
2641
3017
|
(byScope[key] = byScope[key] || []).push(s);
|
|
2642
3018
|
}
|
|
2643
3019
|
const sections = Object.entries(byScope).map(
|
|
2644
3020
|
([scope, stewards]) => `## ${scope}
|
|
2645
|
-
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.
|
|
3021
|
+
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
|
|
2646
3022
|
).join("\n\n");
|
|
2647
3023
|
return `# Data Stewards
|
|
2648
3024
|
|
|
@@ -2705,7 +3081,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
2705
3081
|
if (!node) return `Node not found: ${args.title}`;
|
|
2706
3082
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2707
3083
|
try {
|
|
2708
|
-
const request = approve({
|
|
3084
|
+
const request = await approve({
|
|
2709
3085
|
nestId: ctx.nestId,
|
|
2710
3086
|
nodeId: node.id,
|
|
2711
3087
|
version: currentVersion,
|
|
@@ -2760,19 +3136,17 @@ ${list}`;
|
|
|
2760
3136
|
}
|
|
2761
3137
|
case "context_assign_steward": {
|
|
2762
3138
|
const scope = args.scope;
|
|
2763
|
-
if (!["nest", "tag", "
|
|
2764
|
-
return `Invalid scope "${args.scope}". Use: nest, tag,
|
|
3139
|
+
if (!["nest", "tag", "document"].includes(scope)) {
|
|
3140
|
+
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
2765
3141
|
}
|
|
2766
3142
|
try {
|
|
2767
3143
|
assignSteward({
|
|
2768
3144
|
nestId: ctx.nestId,
|
|
2769
3145
|
scope,
|
|
2770
|
-
nodePattern: scope === "
|
|
3146
|
+
nodePattern: scope === "document" ? args.target : void 0,
|
|
2771
3147
|
tagName: scope === "tag" ? args.target : void 0,
|
|
2772
3148
|
userEmail: args.email,
|
|
2773
3149
|
role: args.role || "reviewer",
|
|
2774
|
-
canApprove: true,
|
|
2775
|
-
canReject: true,
|
|
2776
3150
|
assignedBy: ctx.userEmail,
|
|
2777
3151
|
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2778
3152
|
isActive: true
|
|
@@ -2863,9 +3237,6 @@ function parseStewardsYaml(content) {
|
|
|
2863
3237
|
if (!currentSection || currentEntries.length === 0) return;
|
|
2864
3238
|
if (currentSection === "nest") {
|
|
2865
3239
|
result.nest = [...result.nest || [], ...currentEntries];
|
|
2866
|
-
} else if (currentSection === "folders" && currentTarget) {
|
|
2867
|
-
result.folders = result.folders || {};
|
|
2868
|
-
result.folders[currentTarget] = currentEntries;
|
|
2869
3240
|
} else if (currentSection === "tags" && currentTarget) {
|
|
2870
3241
|
result.tags = result.tags || {};
|
|
2871
3242
|
result.tags[currentTarget] = currentEntries;
|
|
@@ -2885,12 +3256,12 @@ function parseStewardsYaml(content) {
|
|
|
2885
3256
|
if (key === "version") continue;
|
|
2886
3257
|
if (key === "nest" || key === "data_room") {
|
|
2887
3258
|
currentSection = "nest";
|
|
2888
|
-
} else if (key === "folders") {
|
|
2889
|
-
currentSection = "folders";
|
|
2890
3259
|
} else if (key === "tags") {
|
|
2891
3260
|
currentSection = "tags";
|
|
2892
3261
|
} else if (key === "documents") {
|
|
2893
3262
|
currentSection = "documents";
|
|
3263
|
+
} else if (key === "folders") {
|
|
3264
|
+
currentSection = null;
|
|
2894
3265
|
}
|
|
2895
3266
|
continue;
|
|
2896
3267
|
}
|
|
@@ -2916,12 +3287,6 @@ function parseEntry(str) {
|
|
|
2916
3287
|
const entry = { email: emailMatch[1] };
|
|
2917
3288
|
const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
|
|
2918
3289
|
if (roleMatch) entry.role = roleMatch[1];
|
|
2919
|
-
if (str.includes("can_approve:")) {
|
|
2920
|
-
entry.can_approve = str.includes("can_approve: true");
|
|
2921
|
-
}
|
|
2922
|
-
if (str.includes("can_reject:")) {
|
|
2923
|
-
entry.can_reject = str.includes("can_reject: true");
|
|
2924
|
-
}
|
|
2925
3290
|
return entry;
|
|
2926
3291
|
}
|
|
2927
3292
|
function loadStewardsConfig(nestId) {
|
|
@@ -2959,12 +3324,16 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
2959
3324
|
const body = await c.req.json();
|
|
2960
3325
|
const assignedBy = getUserEmail3(c);
|
|
2961
3326
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
3327
|
+
if (body.scope === "folder") {
|
|
3328
|
+
throw new ValidationError(
|
|
3329
|
+
"folder scope is no longer supported \u2014 use 'nest' or 'document'"
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
2962
3332
|
if (Array.isArray(body.users)) {
|
|
2963
3333
|
const created2 = await createStewardRecord({
|
|
2964
3334
|
nestId,
|
|
2965
3335
|
scope: body.scope,
|
|
2966
3336
|
documentId: body.documentId,
|
|
2967
|
-
folderPath: body.folderPath,
|
|
2968
3337
|
tagName: body.tagName,
|
|
2969
3338
|
users: body.users,
|
|
2970
3339
|
assignedBy
|
|
@@ -2978,14 +3347,11 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
2978
3347
|
nestId,
|
|
2979
3348
|
scope: body.scope,
|
|
2980
3349
|
documentId: body.scope === "document" ? body.nodePattern : void 0,
|
|
2981
|
-
folderPath: body.scope === "folder" ? body.nodePattern : void 0,
|
|
2982
3350
|
tagName: body.scope === "tag" ? body.tagName : void 0,
|
|
2983
3351
|
users: [
|
|
2984
3352
|
{
|
|
2985
3353
|
email: body.email,
|
|
2986
|
-
role: body.role
|
|
2987
|
-
canApprove: body.canApprove,
|
|
2988
|
-
canReject: body.canReject
|
|
3354
|
+
role: body.role
|
|
2989
3355
|
}
|
|
2990
3356
|
],
|
|
2991
3357
|
assignedBy
|
|
@@ -3019,6 +3385,21 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3019
3385
|
});
|
|
3020
3386
|
return c.json(result);
|
|
3021
3387
|
});
|
|
3388
|
+
governanceRoutes.get("/external-edits", async (c) => {
|
|
3389
|
+
const nestId = c.req.param("nestId");
|
|
3390
|
+
const refresh = c.req.query("refresh") === "true";
|
|
3391
|
+
if (refresh) {
|
|
3392
|
+
await scanNestForDrift(nestId, "user:refresh");
|
|
3393
|
+
}
|
|
3394
|
+
const entries = await listNestExternalEdits(nestId);
|
|
3395
|
+
return c.json({ entries, total: entries.length });
|
|
3396
|
+
});
|
|
3397
|
+
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
3398
|
+
const nestId = c.req.param("nestId");
|
|
3399
|
+
const actor = getUserEmail3(c);
|
|
3400
|
+
const result = await scanNestForDrift(nestId, actor);
|
|
3401
|
+
return c.json(result);
|
|
3402
|
+
});
|
|
3022
3403
|
var governanceNodeRoutes = new Hono7();
|
|
3023
3404
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3024
3405
|
const nestId = c.req.param("nestId");
|
|
@@ -3031,8 +3412,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
3031
3412
|
role: r.steward.role,
|
|
3032
3413
|
scope: r.steward.scope,
|
|
3033
3414
|
source: r.source,
|
|
3034
|
-
priority: r.priority
|
|
3035
|
-
canApprove: r.steward.canApprove
|
|
3415
|
+
priority: r.priority
|
|
3036
3416
|
})),
|
|
3037
3417
|
fallbackToOwner,
|
|
3038
3418
|
ownerEmail
|
|
@@ -3101,7 +3481,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
3101
3481
|
const userEmail = getUserEmail3(c);
|
|
3102
3482
|
const isAdmin = isSuperAdmin(userEmail);
|
|
3103
3483
|
try {
|
|
3104
|
-
const request = approve({
|
|
3484
|
+
const request = await approve({
|
|
3105
3485
|
nestId,
|
|
3106
3486
|
nodeId,
|
|
3107
3487
|
version: getCurrentVersion(nestId, nodeId),
|
|
@@ -3153,6 +3533,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
|
3153
3533
|
const userEmail = getUserEmail3(c);
|
|
3154
3534
|
return c.json(canUserEdit(nestId, nodeId, userEmail));
|
|
3155
3535
|
});
|
|
3536
|
+
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
3537
|
+
const nestId = c.req.param("nestId");
|
|
3538
|
+
const nodeId = c.req.param("nodeId");
|
|
3539
|
+
const pending = await getPendingChange(nestId, nodeId);
|
|
3540
|
+
return c.json({ pending });
|
|
3541
|
+
});
|
|
3542
|
+
governanceNodeRoutes.get(
|
|
3543
|
+
"/:nodeId{.+?}/external-edits/:suggestionId",
|
|
3544
|
+
async (c) => {
|
|
3545
|
+
const nestId = c.req.param("nestId");
|
|
3546
|
+
const nodeId = c.req.param("nodeId");
|
|
3547
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3548
|
+
const detail = await getExternalEditDetail(
|
|
3549
|
+
nestId,
|
|
3550
|
+
nodeId,
|
|
3551
|
+
suggestionId
|
|
3552
|
+
);
|
|
3553
|
+
if (!detail) {
|
|
3554
|
+
return c.json({ error: "Suggestion not found" }, 404);
|
|
3555
|
+
}
|
|
3556
|
+
return c.json({ entry: detail });
|
|
3557
|
+
}
|
|
3558
|
+
);
|
|
3559
|
+
governanceNodeRoutes.post(
|
|
3560
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/approve",
|
|
3561
|
+
async (c) => {
|
|
3562
|
+
const nestId = c.req.param("nestId");
|
|
3563
|
+
const nodeId = c.req.param("nodeId");
|
|
3564
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3565
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3566
|
+
const actor = getUserEmail3(c);
|
|
3567
|
+
try {
|
|
3568
|
+
const result = await approveExternalEdit({
|
|
3569
|
+
nestId,
|
|
3570
|
+
documentId: nodeId,
|
|
3571
|
+
suggestionId,
|
|
3572
|
+
actor,
|
|
3573
|
+
comment: body.comment
|
|
3574
|
+
});
|
|
3575
|
+
return c.json({
|
|
3576
|
+
approved: true,
|
|
3577
|
+
version: result.versionEntry.version,
|
|
3578
|
+
chainEvent: result.chainEvent.event_id
|
|
3579
|
+
});
|
|
3580
|
+
} catch (err) {
|
|
3581
|
+
console.error(
|
|
3582
|
+
`[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
|
|
3583
|
+
{ message: err?.message, name: err?.name, stack: err?.stack }
|
|
3584
|
+
);
|
|
3585
|
+
return c.json(
|
|
3586
|
+
{ error: err.message, name: err?.name },
|
|
3587
|
+
400
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
);
|
|
3592
|
+
governanceNodeRoutes.post(
|
|
3593
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/reject",
|
|
3594
|
+
async (c) => {
|
|
3595
|
+
const nestId = c.req.param("nestId");
|
|
3596
|
+
const nodeId = c.req.param("nodeId");
|
|
3597
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3598
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3599
|
+
if (!body.reason) {
|
|
3600
|
+
throw new ValidationError("Rejection reason is required");
|
|
3601
|
+
}
|
|
3602
|
+
const actor = getUserEmail3(c);
|
|
3603
|
+
try {
|
|
3604
|
+
const result = await rejectExternalEdit({
|
|
3605
|
+
nestId,
|
|
3606
|
+
documentId: nodeId,
|
|
3607
|
+
suggestionId,
|
|
3608
|
+
actor,
|
|
3609
|
+
reason: body.reason
|
|
3610
|
+
});
|
|
3611
|
+
return c.json({
|
|
3612
|
+
rejected: true,
|
|
3613
|
+
chainEvent: result.chainEvent.event_id
|
|
3614
|
+
});
|
|
3615
|
+
} catch (err) {
|
|
3616
|
+
return c.json({ error: err.message }, 400);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
);
|
|
3156
3620
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
3157
3621
|
const nestId = c.req.param("nestId");
|
|
3158
3622
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3173,18 +3637,16 @@ function getUserEmail3(c) {
|
|
|
3173
3637
|
|
|
3174
3638
|
// src/auth/anonymous.ts
|
|
3175
3639
|
import bcrypt from "bcryptjs";
|
|
3176
|
-
var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
|
|
3177
|
-
var ANON_EMAIL = "admin@localhost";
|
|
3178
3640
|
function ensureAnonymousUser() {
|
|
3179
3641
|
const db = getDb();
|
|
3180
|
-
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(
|
|
3642
|
+
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
|
|
3181
3643
|
if (!exists) {
|
|
3182
3644
|
const placeholder = bcrypt.hashSync("anon-no-login", 4);
|
|
3183
3645
|
db.prepare(
|
|
3184
3646
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
3185
|
-
).run(
|
|
3647
|
+
).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
|
|
3186
3648
|
}
|
|
3187
|
-
return
|
|
3649
|
+
return ANON_USER_ID;
|
|
3188
3650
|
}
|
|
3189
3651
|
|
|
3190
3652
|
// src/app.ts
|
|
@@ -3308,7 +3770,7 @@ function createApp() {
|
|
|
3308
3770
|
return c.json(
|
|
3309
3771
|
{
|
|
3310
3772
|
valid: false,
|
|
3311
|
-
error: "
|
|
3773
|
+
error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
|
|
3312
3774
|
},
|
|
3313
3775
|
400
|
|
3314
3776
|
);
|
|
@@ -3324,6 +3786,28 @@ function createApp() {
|
|
|
3324
3786
|
return c.json({ error: msg }, 500);
|
|
3325
3787
|
}
|
|
3326
3788
|
});
|
|
3789
|
+
app.use("/stats", flexAuthMiddleware);
|
|
3790
|
+
app.get("/stats", async (c) => {
|
|
3791
|
+
const db = getDb();
|
|
3792
|
+
const userId = c.get("userId");
|
|
3793
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3794
|
+
const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
|
|
3795
|
+
let documents = 0;
|
|
3796
|
+
for (const nest of visibleNests) {
|
|
3797
|
+
try {
|
|
3798
|
+
const { storage } = engineCache.get(nest.id);
|
|
3799
|
+
const docs = await storage.discoverDocuments();
|
|
3800
|
+
documents += filterAccessible(nest.id, userEmail, docs).length;
|
|
3801
|
+
} catch {
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
|
|
3805
|
+
return c.json({
|
|
3806
|
+
nests: visibleNests.length,
|
|
3807
|
+
documents,
|
|
3808
|
+
users: usersRow.c
|
|
3809
|
+
});
|
|
3810
|
+
});
|
|
3327
3811
|
const nestsApp = new Hono8();
|
|
3328
3812
|
nestsApp.use("*", flexAuthMiddleware);
|
|
3329
3813
|
nestsApp.use("*", async (c, next) => {
|
|
@@ -3479,9 +3963,108 @@ function createApp() {
|
|
|
3479
3963
|
return app;
|
|
3480
3964
|
}
|
|
3481
3965
|
|
|
3966
|
+
// src/db/backfill.ts
|
|
3967
|
+
import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
|
|
3968
|
+
import { join as join5 } from "path";
|
|
3969
|
+
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3970
|
+
async function backfillNodeVersionsFromHistory(db) {
|
|
3971
|
+
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
3972
|
+
if (already) return;
|
|
3973
|
+
const nests = db.prepare("SELECT id FROM nests").all();
|
|
3974
|
+
const insert = db.prepare(
|
|
3975
|
+
`INSERT OR IGNORE INTO node_versions
|
|
3976
|
+
(nest_id, node_id, version, content_hash, author, status, change_note, created_at)
|
|
3977
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3978
|
+
);
|
|
3979
|
+
const approvedPin = db.prepare(
|
|
3980
|
+
`INSERT OR REPLACE INTO approved_versions
|
|
3981
|
+
(nest_id, node_id, approved_version, approved_by, approved_at)
|
|
3982
|
+
VALUES (?, ?, ?, ?, COALESCE(
|
|
3983
|
+
(SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
|
|
3984
|
+
datetime('now')))`
|
|
3985
|
+
);
|
|
3986
|
+
let totalInserted = 0;
|
|
3987
|
+
let totalDocs = 0;
|
|
3988
|
+
for (const { id: nestId } of nests) {
|
|
3989
|
+
const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
|
|
3990
|
+
const storage = new NestStorage2(nestPath2);
|
|
3991
|
+
let docs;
|
|
3992
|
+
try {
|
|
3993
|
+
docs = await storage.discoverDocuments();
|
|
3994
|
+
} catch (err) {
|
|
3995
|
+
console.warn(
|
|
3996
|
+
`[backfill] discoverDocuments failed for nest ${nestId}:`,
|
|
3997
|
+
err.message
|
|
3998
|
+
);
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
for (const doc of docs) {
|
|
4002
|
+
totalDocs += 1;
|
|
4003
|
+
let history;
|
|
4004
|
+
try {
|
|
4005
|
+
history = await storage.readHistory(doc.id);
|
|
4006
|
+
} catch {
|
|
4007
|
+
history = null;
|
|
4008
|
+
}
|
|
4009
|
+
if (!history || history.versions.length === 0) continue;
|
|
4010
|
+
const existing = db.prepare(
|
|
4011
|
+
`SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
|
|
4012
|
+
).all(nestId, doc.id);
|
|
4013
|
+
const known = new Set(existing.map((r) => r.version));
|
|
4014
|
+
const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
|
|
4015
|
+
const latestVersion = history.versions[history.versions.length - 1].version;
|
|
4016
|
+
for (const entry of history.versions) {
|
|
4017
|
+
if (known.has(entry.version)) continue;
|
|
4018
|
+
insert.run(
|
|
4019
|
+
nestId,
|
|
4020
|
+
doc.id,
|
|
4021
|
+
entry.version,
|
|
4022
|
+
entry.content_hash || "",
|
|
4023
|
+
entry.edited_by || "system:backfill",
|
|
4024
|
+
"approved",
|
|
4025
|
+
entry.note || null,
|
|
4026
|
+
entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
4027
|
+
);
|
|
4028
|
+
totalInserted += 1;
|
|
4029
|
+
}
|
|
4030
|
+
const pin = db.prepare(
|
|
4031
|
+
`SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
|
|
4032
|
+
).get(nestId, doc.id);
|
|
4033
|
+
if (!pin || pin.approved_version < latestVersion) {
|
|
4034
|
+
approvedPin.run(
|
|
4035
|
+
nestId,
|
|
4036
|
+
doc.id,
|
|
4037
|
+
latestVersion,
|
|
4038
|
+
history.versions[history.versions.length - 1].edited_by || "system:backfill",
|
|
4039
|
+
nestId,
|
|
4040
|
+
doc.id
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
if (tagsJson) {
|
|
4044
|
+
const updateTags = db.prepare(
|
|
4045
|
+
`UPDATE node_versions SET tags_json = ?
|
|
4046
|
+
WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
|
|
4047
|
+
);
|
|
4048
|
+
updateTags.run(tagsJson, nestId, doc.id, latestVersion);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
|
|
4053
|
+
MIGRATION_ID
|
|
4054
|
+
);
|
|
4055
|
+
console.log(
|
|
4056
|
+
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
4057
|
+
);
|
|
4058
|
+
}
|
|
4059
|
+
|
|
3482
4060
|
// src/index.ts
|
|
3483
4061
|
async function main() {
|
|
3484
|
-
getDb();
|
|
4062
|
+
const db = getDb();
|
|
4063
|
+
try {
|
|
4064
|
+
await backfillNodeVersionsFromHistory(db);
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
console.error("[backfill] node_versions backfill failed:", err);
|
|
4067
|
+
}
|
|
3485
4068
|
const accessCfg = loadAccessConfig();
|
|
3486
4069
|
if (accessCfg) {
|
|
3487
4070
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|
|
@@ -3503,6 +4086,11 @@ async function main() {
|
|
|
3503
4086
|
}
|
|
3504
4087
|
const app = createApp();
|
|
3505
4088
|
startLicenseWatcher();
|
|
4089
|
+
startLicenseSafetyPoll();
|
|
4090
|
+
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|
|
4091
|
+
if (driftScanIntervalMs > 0) {
|
|
4092
|
+
startDriftScanner(driftScanIntervalMs);
|
|
4093
|
+
}
|
|
3506
4094
|
startTelemetryLoop();
|
|
3507
4095
|
trackEvent("server.start", {
|
|
3508
4096
|
tier: license.tier,
|