fraim-hub 2.0.344 → 2.0.346
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/registry/scripts/learning/completion-state.js +44 -1
- package/dist/registry/scripts/learning/corpus-audit.js +205 -0
- package/dist/registry/scripts/learning/owner-identity.js +137 -0
- package/dist/src/ai-hub/conversation-store.js +61 -1
- package/dist/src/ai-hub/server.js +1 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +59 -109
- package/dist/src/local-mcp-server/learning-source-record.js +17 -8
- package/package.json +6 -2
- package/public/ai-hub/review.css +5 -1
- package/public/ai-hub/script.js +207 -104
- package/public/ai-hub/styles.css +1 -1
|
@@ -16,6 +16,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
16
16
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
17
|
const node_crypto_1 = require("node:crypto");
|
|
18
18
|
const artifact_paths_1 = require("../artifact-paths");
|
|
19
|
+
const corpus_audit_1 = require("./corpus-audit");
|
|
19
20
|
const sourcePathKey = (file) => {
|
|
20
21
|
const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
|
|
21
22
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
@@ -233,7 +234,7 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
233
234
|
catch {
|
|
234
235
|
fail();
|
|
235
236
|
}
|
|
236
|
-
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
237
|
+
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
237
238
|
|| !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
|
|
238
239
|
fail();
|
|
239
240
|
if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
|
|
@@ -281,6 +282,48 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
281
282
|
fail();
|
|
282
283
|
}
|
|
283
284
|
const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
|
|
285
|
+
if (state.corpusAudit !== undefined) {
|
|
286
|
+
try {
|
|
287
|
+
(0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
fail();
|
|
291
|
+
}
|
|
292
|
+
for (const item of state.corpusAudit.dispositions)
|
|
293
|
+
for (const binding of item.actionBindings) {
|
|
294
|
+
const decision = decisions.get(binding.actionId);
|
|
295
|
+
if (!decision || decision.digest !== binding.digest || decision.revision !== binding.revision || decision.decision.reviewer !== state.corpusAudit.reviewer)
|
|
296
|
+
fail();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (state.reconciliationHistory !== undefined) {
|
|
300
|
+
if (!Array.isArray(state.reconciliationHistory))
|
|
301
|
+
fail();
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
const validRef = (ref) => keys(ref, ['owner', 'relativePath', 'entryId', 'learningSubject']) && ownerValid(ref.owner)
|
|
304
|
+
&& typeof ref.relativePath === 'string' && ref.relativePath.startsWith('learnings/') && !ref.relativePath.includes('\\') && !ref.relativePath.includes(':')
|
|
305
|
+
&& !ref.relativePath.split('/').some((part) => !part || part === '.' || part === '..') && /^L-[a-f0-9]{10}$/.test(ref.entryId)
|
|
306
|
+
&& (ref.learningSubject === undefined || text(ref.learningSubject));
|
|
307
|
+
for (const history of state.reconciliationHistory) {
|
|
308
|
+
const decision = decisions.get(history.actionId);
|
|
309
|
+
if (!keys(history, ['actionId', 'revision', 'digest', 'target', 'resultHash', 'occurrences']) || !decision || seen.has(history.actionId)
|
|
310
|
+
|| decision.decision.action !== 'approve' || history.revision !== decision.revision || history.digest !== decision.digest
|
|
311
|
+
|| !validRef(history.target) || canonicalJournal(history.target) !== canonicalJournal(decision.target) || !hash(history.resultHash)
|
|
312
|
+
|| !Array.isArray(history.occurrences) || !history.occurrences.length)
|
|
313
|
+
fail();
|
|
314
|
+
seen.add(history.actionId);
|
|
315
|
+
const locations = new Set();
|
|
316
|
+
for (const occurrence of history.occurrences) {
|
|
317
|
+
if (!keys(occurrence, ['ref', 'sha256', 'before']) || !validRef(occurrence.ref) || occurrence.ref.entryId !== history.target.entryId
|
|
318
|
+
|| !text(occurrence.before) || !hash(occurrence.sha256) || (0, node_crypto_1.createHash)('sha256').update(occurrence.before).digest('hex') !== occurrence.sha256)
|
|
319
|
+
fail();
|
|
320
|
+
const location = canonicalJournal([occurrence.ref.owner, occurrence.ref.relativePath]);
|
|
321
|
+
if (locations.has(location))
|
|
322
|
+
fail();
|
|
323
|
+
locations.add(location);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
284
327
|
const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
|
|
285
328
|
const dispositions = new Set();
|
|
286
329
|
for (const source of state.sourceDispositions) {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.corpusEntryKey = corpusEntryKey;
|
|
7
|
+
exports.collectLearningCorpus = collectLearningCorpus;
|
|
8
|
+
exports.validateCorpusAudit = validateCorpusAudit;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const owner_identity_1 = require("./owner-identity");
|
|
13
|
+
const { learningEntryBlocks } = require('./entry-format');
|
|
14
|
+
const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
15
|
+
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
16
|
+
const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
17
|
+
const keys = (value, allowed) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key));
|
|
18
|
+
function corpusEntryKey(entry) {
|
|
19
|
+
return (0, owner_identity_1.ownerKey)(entry.ref.owner) + ':' + entry.ref.relativePath + ':' + (entry.ref.entryId ?? `unassigned:${entry.occurrence}`);
|
|
20
|
+
}
|
|
21
|
+
function corpusFingerprint(homes, entries) { return hash(stable({ homes, entries })); }
|
|
22
|
+
/** Only persisted identity and bytes are mechanical; audience and semantic conflicts require review. */
|
|
23
|
+
function collectLearningCorpus(homes, subjects, readerRoots = []) {
|
|
24
|
+
const entries = [], errors = [], seen = new Set();
|
|
25
|
+
const identities = [...new Map(homes.map(home => [(0, owner_identity_1.ownerKey)(home.owner), home.owner])).values()].sort((a, b) => (0, owner_identity_1.ownerKey)(a).localeCompare((0, owner_identity_1.ownerKey)(b)));
|
|
26
|
+
const escaped = [...new Set([...subjects, 'org'])].map(subject => subject.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
27
|
+
const family = new RegExp(`^(?:${escaped.join('|')})-(?:[a-z][a-z-]*-)?(?:preferences|mistake-patterns|validated-patterns|manager-coaching)(\\.cold)?\\.md$`);
|
|
28
|
+
const excluded = new Set(['raw', 'retrospectives', 'archive', 'review-queue', 'review-archive', 'review-evidence']);
|
|
29
|
+
for (const home of [...homes].sort((a, b) => (0, owner_identity_1.ownerKey)(a.owner).localeCompare((0, owner_identity_1.ownerKey)(b.owner)))) {
|
|
30
|
+
const walk = (directory) => {
|
|
31
|
+
try {
|
|
32
|
+
if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
|
|
33
|
+
throw new Error('symlink-corpus-not-followed');
|
|
34
|
+
for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
35
|
+
const file = node_path_1.default.join(directory, item.name);
|
|
36
|
+
if (item.isSymbolicLink()) {
|
|
37
|
+
if (!excluded.has(item.name))
|
|
38
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (item.isDirectory()) {
|
|
42
|
+
if (!excluded.has(item.name))
|
|
43
|
+
walk(file);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!family.test(item.name))
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
if (item.isSymbolicLink())
|
|
50
|
+
throw new Error('symlink-corpus-not-followed');
|
|
51
|
+
const real = node_fs_1.default.realpathSync(file);
|
|
52
|
+
const key = process.platform === 'win32' ? real.toLowerCase() : real;
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
const relativePath = node_path_1.default.relative(home.contentRoot, file).replace(/\\/g, '/');
|
|
57
|
+
(0, owner_identity_1.ownerArtifactPath)(home, relativePath);
|
|
58
|
+
const body = node_fs_1.default.readFileSync(file, 'utf8');
|
|
59
|
+
if (/^<{7}/m.test(body))
|
|
60
|
+
throw new Error('corpus-merge-conflict');
|
|
61
|
+
const blocks = learningEntryBlocks(body), ids = new Set();
|
|
62
|
+
let unassigned = 0;
|
|
63
|
+
for (const block of blocks) {
|
|
64
|
+
// Match the runtime reader's handwritten identity contract.
|
|
65
|
+
// Approved mutations still use the author's narrower generated-ID schema.
|
|
66
|
+
const declarations = block.text.split(/\r?\n/).map(line => line.trim()).filter(line => /^\*\*Id\*\*:/i.test(line));
|
|
67
|
+
const id = declarations[0]?.match(/^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/)?.[1] ?? null;
|
|
68
|
+
if ((declarations.length && (!id || declarations.length !== 1)) || (id && ids.has(id))
|
|
69
|
+
|| !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
|
|
70
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (id)
|
|
74
|
+
ids.add(id);
|
|
75
|
+
entries.push({ ref: { owner: home.owner, relativePath, ...(id ? { entryId: id } : {}) }, sha256: hash(block.text),
|
|
76
|
+
...(!id ? { occurrence: unassigned++ } : {}) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error.code !== 'ENOENT')
|
|
86
|
+
errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
walk((0, owner_identity_1.ownerArtifactPath)(home, 'learnings'));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
errors.push({ path: home.contentRoot, reason: error.message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Legacy reader locations are explicitly discoverable but cannot acquire a
|
|
97
|
+
// writable OwnerRef just by being present. Report every unowned family file.
|
|
98
|
+
const inspectReaderRoot = (directory) => {
|
|
99
|
+
try {
|
|
100
|
+
if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
|
|
101
|
+
throw new Error('symlink-corpus-not-followed');
|
|
102
|
+
for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
103
|
+
const file = node_path_1.default.join(directory, item.name);
|
|
104
|
+
if (excluded.has(item.name))
|
|
105
|
+
continue;
|
|
106
|
+
if (item.isSymbolicLink()) {
|
|
107
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (item.isDirectory()) {
|
|
111
|
+
inspectReaderRoot(file);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!family.test(item.name))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const real = node_fs_1.default.realpathSync(file), key = process.platform === 'win32' ? real.toLowerCase() : real;
|
|
118
|
+
if (seen.has(key))
|
|
119
|
+
continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'reader-live-home-unowned' });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code !== 'ENOENT')
|
|
130
|
+
errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (const directory of [...new Set(readerRoots)].sort()) {
|
|
134
|
+
// An explicit configured alias of an owned learning root is already scanned.
|
|
135
|
+
try {
|
|
136
|
+
const key = (file) => { const real = node_fs_1.default.realpathSync(file); return process.platform === 'win32' ? real.toLowerCase() : real; };
|
|
137
|
+
const real = key(directory);
|
|
138
|
+
if (homes.some(home => { try {
|
|
139
|
+
return key(node_path_1.default.join(home.contentRoot, 'learnings')) === real;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
} }))
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
catch { /* Missing/unreadable roots are handled by the scanner below. */ }
|
|
147
|
+
inspectReaderRoot(directory);
|
|
148
|
+
}
|
|
149
|
+
entries.sort((a, b) => corpusEntryKey(a).localeCompare(corpusEntryKey(b)));
|
|
150
|
+
const groups = new Map();
|
|
151
|
+
for (const entry of entries)
|
|
152
|
+
if (entry.ref.entryId)
|
|
153
|
+
groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
|
|
154
|
+
const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
|
|
155
|
+
return { schemaVersion: 1, homes: identities, entries, duplicates, fingerprint: corpusFingerprint(identities, entries), complete: !errors.length, errors };
|
|
156
|
+
}
|
|
157
|
+
/** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
|
|
158
|
+
function validateCorpusAudit(audit, reviewer) {
|
|
159
|
+
const fail = () => { throw new Error('invalid-corpus-audit'); };
|
|
160
|
+
if (!keys(audit, ['snapshot', 'reviewer', 'dispositions']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer)
|
|
161
|
+
|| !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || audit.snapshot.complete !== true || !Array.isArray(audit.snapshot.errors) || audit.snapshot.errors.length
|
|
162
|
+
|| !Array.isArray(audit.snapshot.homes) || !Array.isArray(audit.snapshot.entries) || !Array.isArray(audit.dispositions))
|
|
163
|
+
fail();
|
|
164
|
+
const snapshot = audit.snapshot;
|
|
165
|
+
for (const owner of snapshot.homes)
|
|
166
|
+
(0, owner_identity_1.validateOwner)(owner);
|
|
167
|
+
if (new Set(snapshot.homes.map(owner_identity_1.ownerKey)).size !== snapshot.homes.length || snapshot.fingerprint !== corpusFingerprint(snapshot.homes, snapshot.entries))
|
|
168
|
+
fail();
|
|
169
|
+
const entries = new Map();
|
|
170
|
+
for (const entry of snapshot.entries) {
|
|
171
|
+
if (!keys(entry, ['ref', 'sha256', 'occurrence']) || !keys(entry.ref, ['owner', 'relativePath', 'entryId']))
|
|
172
|
+
fail();
|
|
173
|
+
(0, owner_identity_1.validateOwner)(entry.ref?.owner);
|
|
174
|
+
if (!snapshot.homes.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(entry.ref.owner))
|
|
175
|
+
|| (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 : !/^L-[0-9A-Za-z]{6,24}$/.test(entry.ref.entryId) || entry.occurrence !== undefined)
|
|
176
|
+
|| !/^learnings\//.test(entry.ref.relativePath) || /[\\:]/.test(entry.ref.relativePath) || entry.ref.relativePath.split('/').some(part => !part || part === '.' || part === '..')
|
|
177
|
+
|| !/^[a-f0-9]{64}$/.test(entry.sha256) || entries.has(corpusEntryKey(entry)))
|
|
178
|
+
fail();
|
|
179
|
+
entries.set(corpusEntryKey(entry), entry);
|
|
180
|
+
}
|
|
181
|
+
const groups = new Map();
|
|
182
|
+
for (const entry of snapshot.entries)
|
|
183
|
+
if (entry.ref.entryId)
|
|
184
|
+
groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
|
|
185
|
+
const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
|
|
186
|
+
if (stable(snapshot.duplicates) !== stable(duplicates))
|
|
187
|
+
fail();
|
|
188
|
+
if (audit.dispositions.length !== entries.size)
|
|
189
|
+
fail();
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
for (const item of audit.dispositions) {
|
|
192
|
+
if (!keys(item, ['ref', 'sha256', 'occurrence', 'outcome', 'rationale', 'actionIds', 'actionBindings']) || !keys(item.ref, ['owner', 'relativePath', 'entryId']))
|
|
193
|
+
fail();
|
|
194
|
+
const key = corpusEntryKey(item);
|
|
195
|
+
if (seen.has(key) || entries.get(key)?.sha256 !== item.sha256 || entries.get(key)?.occurrence !== item.occurrence || !['retained', 'action'].includes(item.outcome)
|
|
196
|
+
|| typeof item.rationale !== 'string' || !item.rationale.trim() || !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
|
|
197
|
+
|| (item.outcome === 'retained' ? item.actionIds.length !== 0 : item.actionIds.length === 0)
|
|
198
|
+
|| !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
|
|
199
|
+
fail();
|
|
200
|
+
for (const [index, binding] of item.actionBindings.entries())
|
|
201
|
+
if (!keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== item.actionIds[index] || !Number.isInteger(binding.revision) || binding.revision < 1 || !/^[a-f0-9]{64}$/.test(binding.digest))
|
|
202
|
+
fail();
|
|
203
|
+
seen.add(key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ownerArtifactRoot = void 0;
|
|
7
|
+
exports.ownerPath = ownerPath;
|
|
8
|
+
exports.validateOwner = validateOwner;
|
|
9
|
+
exports.ownerKey = ownerKey;
|
|
10
|
+
exports.assertSameOwner = assertSameOwner;
|
|
11
|
+
exports.resolveOwnerHome = resolveOwnerHome;
|
|
12
|
+
exports.ownerArtifactPath = ownerArtifactPath;
|
|
13
|
+
exports.configuredArtifactPath = configuredArtifactPath;
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
/** A recorded home path is immutable; moving storage requires a newly reviewed run. */
|
|
17
|
+
function ownerPath(root) {
|
|
18
|
+
const absolute = node_path_1.default.resolve(root).replace(/\\/g, '/');
|
|
19
|
+
return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
|
|
20
|
+
}
|
|
21
|
+
function validateOwner(owner) {
|
|
22
|
+
if (!owner || !['Project', 'Manager', 'Org'].includes(owner.level)
|
|
23
|
+
|| Object.keys(owner).some(key => !['level', 'path'].includes(key))
|
|
24
|
+
|| typeof owner.path !== 'string' || !node_path_1.default.isAbsolute(owner.path)
|
|
25
|
+
|| /[\x00-\x1f]/.test(owner.path) || owner.path !== ownerPath(owner.path)) {
|
|
26
|
+
throw new Error('owner-unresolved: a canonical absolute home path is required');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function ownerKey(owner) {
|
|
30
|
+
validateOwner(owner);
|
|
31
|
+
return JSON.stringify([owner.level, owner.path]);
|
|
32
|
+
}
|
|
33
|
+
function assertSameOwner(expected, actual) {
|
|
34
|
+
if (ownerKey(expected) !== ownerKey(actual))
|
|
35
|
+
throw new Error('owner-path-conflict: the approved home differs from the configured path');
|
|
36
|
+
}
|
|
37
|
+
/** Pure read: resolve existing configured homes without migration or identity configuration. */
|
|
38
|
+
function resolveOwnerHome(projectPath, level, options) {
|
|
39
|
+
const load = (relative) => options.runtimeModule(projectPath, relative);
|
|
40
|
+
const paths = load('core/utils/project-fraim-paths');
|
|
41
|
+
let home;
|
|
42
|
+
if (level === 'Project') {
|
|
43
|
+
const root = paths.getWorkspaceFraimPath(projectPath);
|
|
44
|
+
home = { root, artifactRoot: node_path_1.default.resolve(projectPath), contentRoot: node_path_1.default.join(root, 'personalized-employee'), requiresPublish: false, backend: 'project' };
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
if (!['Manager', 'Org'].includes(level))
|
|
48
|
+
throw new Error('owner-unresolved: invalid owner level');
|
|
49
|
+
const configFile = node_path_1.default.join(paths.getUserFraimDirPath(), 'config.json');
|
|
50
|
+
let config;
|
|
51
|
+
try {
|
|
52
|
+
config = node_fs_1.default.existsSync(configFile) ? JSON.parse(node_fs_1.default.readFileSync(configFile, 'utf8')) : {};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error('home-unavailable: user configuration is invalid');
|
|
56
|
+
}
|
|
57
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
58
|
+
throw new Error('home-unavailable: user configuration is invalid');
|
|
59
|
+
const raw = level === 'Manager' ? config.managerStorage : config.organization;
|
|
60
|
+
if (raw !== undefined && (!raw || typeof raw !== 'object' || Array.isArray(raw)
|
|
61
|
+
|| !['git', 'local-folder', 'fraim-cloud', 'single-machine'].includes(raw.backend)
|
|
62
|
+
|| (raw.backend === 'git' && (typeof raw.gitUrl !== 'string' || !raw.gitUrl.trim()))
|
|
63
|
+
|| (raw.backend === 'local-folder' && (typeof raw.localPath !== 'string' || !raw.localPath.trim())))) {
|
|
64
|
+
throw new Error('home-unavailable: invalid configured storage home');
|
|
65
|
+
}
|
|
66
|
+
const resolved = load('cli/utils/pack-home').resolvePackHome(level === 'Manager' ? 'manager' : 'org', { migrateLegacy: false });
|
|
67
|
+
home = { root: resolved.root, contentRoot: resolved.contentRoot, requiresPublish: resolved.requiresPublish, backend: resolved.backend, ...(resolved.gitUrl ? { gitUrl: resolved.gitUrl } : {}) };
|
|
68
|
+
}
|
|
69
|
+
const owner = { level, path: ownerPath(home.root) };
|
|
70
|
+
if (options.expectedOwner)
|
|
71
|
+
assertSameOwner(options.expectedOwner, owner);
|
|
72
|
+
if (!node_fs_1.default.existsSync(home.root) || !node_fs_1.default.statSync(home.root).isDirectory()
|
|
73
|
+
|| (home.backend === 'git' && !node_fs_1.default.existsSync(node_path_1.default.join(home.root, '.git')))) {
|
|
74
|
+
throw new Error('home-unavailable: configured home is not available locally');
|
|
75
|
+
}
|
|
76
|
+
return { ...home, level, owner };
|
|
77
|
+
}
|
|
78
|
+
/** Resolve a relative artifact without permitting lexical or realpath escapes, including junctions. */
|
|
79
|
+
function ownerArtifactPath(home, relativePath) {
|
|
80
|
+
assertSameOwner(home.owner, { level: home.level, path: ownerPath(home.root) });
|
|
81
|
+
if (!relativePath || node_path_1.default.isAbsolute(relativePath) || relativePath.includes('\\')
|
|
82
|
+
|| relativePath.includes(':') || relativePath.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
83
|
+
throw new Error('invalid-artifact-path');
|
|
84
|
+
}
|
|
85
|
+
// Shared project capabilities live in the repository registry, outside its personalized pack.
|
|
86
|
+
const shared = home.level === 'Project' && relativePath.startsWith('registry/');
|
|
87
|
+
const containmentRoot = shared ? node_path_1.default.dirname(home.root) : home.root;
|
|
88
|
+
// The workspace configuration has one explicit reference; arbitrary parent paths stay forbidden.
|
|
89
|
+
const workspaceConfig = home.level === 'Project' && relativePath === 'config.json';
|
|
90
|
+
const contentRoot = shared ? containmentRoot : workspaceConfig ? home.root : home.contentRoot;
|
|
91
|
+
const target = node_path_1.default.resolve(contentRoot, relativePath);
|
|
92
|
+
const existingAncestor = (file) => {
|
|
93
|
+
let current = file;
|
|
94
|
+
while (!node_fs_1.default.existsSync(current)) {
|
|
95
|
+
const parent = node_path_1.default.dirname(current);
|
|
96
|
+
if (parent === current)
|
|
97
|
+
throw new Error('home-unavailable');
|
|
98
|
+
current = parent;
|
|
99
|
+
}
|
|
100
|
+
return current;
|
|
101
|
+
};
|
|
102
|
+
const realRoot = node_fs_1.default.realpathSync(containmentRoot);
|
|
103
|
+
const ancestor = node_fs_1.default.realpathSync(existingAncestor(target));
|
|
104
|
+
const relative = node_path_1.default.relative(realRoot, ancestor);
|
|
105
|
+
if (relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
|
|
106
|
+
throw new Error('invalid-artifact-path: escaped owner home');
|
|
107
|
+
// Also reject escaping a content root via a symlink to a sibling in the pack.
|
|
108
|
+
if (node_fs_1.default.existsSync(contentRoot)) {
|
|
109
|
+
const contentRelative = node_path_1.default.relative(node_fs_1.default.realpathSync(contentRoot), ancestor);
|
|
110
|
+
if (contentRelative === '..' || contentRelative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(contentRelative))
|
|
111
|
+
throw new Error('invalid-artifact-path: escaped content root');
|
|
112
|
+
}
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
115
|
+
/** Explicit artifact base; never infer a project from its personalized directory. */
|
|
116
|
+
const ownerArtifactRoot = (home) => home.artifactRoot ?? home.root;
|
|
117
|
+
exports.ownerArtifactRoot = ownerArtifactRoot;
|
|
118
|
+
/** Configured artifacts may sit outside the personalized pack, but never outside their owner. */
|
|
119
|
+
function configuredArtifactPath(home, file) {
|
|
120
|
+
assertSameOwner(home.owner, { level: home.level, path: ownerPath(home.root) });
|
|
121
|
+
const root = node_path_1.default.resolve((0, exports.ownerArtifactRoot)(home));
|
|
122
|
+
const target = node_path_1.default.resolve(file);
|
|
123
|
+
const relative = node_path_1.default.relative(root, target);
|
|
124
|
+
if (!relative || relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
|
|
125
|
+
throw new Error('invalid-artifact-path: escaped owner home');
|
|
126
|
+
for (let current = target; ownerPath(current) !== ownerPath(root); current = node_path_1.default.dirname(current)) {
|
|
127
|
+
try {
|
|
128
|
+
if (node_fs_1.default.lstatSync(current).isSymbolicLink())
|
|
129
|
+
throw new Error('invalid-artifact-path: linked artifact');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code !== 'ENOENT')
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return target;
|
|
137
|
+
}
|
|
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
13
13
|
const conversation_store_lock_1 = require("./conversation-store-lock");
|
|
14
|
+
const proposal_set_contract_1 = require("../core/proposal-set-contract");
|
|
14
15
|
const atomic_json_file_1 = require("./atomic-json-file");
|
|
15
16
|
const conversation_search_projection_1 = require("./conversation-search-projection");
|
|
16
17
|
// Issue #708: reserved, project-independent bucket keys for non-project scopes.
|
|
@@ -169,6 +170,45 @@ function conversationContinuityCompatible(left, right) {
|
|
|
169
170
|
return false;
|
|
170
171
|
return true;
|
|
171
172
|
}
|
|
173
|
+
// Acknowledged review snapshots are append-only presentation history. Merge under
|
|
174
|
+
// the bucket lock so delayed browser patches and richer run projections cannot erase them.
|
|
175
|
+
function mergeLearningReviewTurns(...sources) {
|
|
176
|
+
const turns = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const source of sources) {
|
|
179
|
+
if (!Array.isArray(source))
|
|
180
|
+
continue;
|
|
181
|
+
for (const turn of source) {
|
|
182
|
+
if (!turn || !Number.isInteger(turn.messageCount) || turn.messageCount < 0
|
|
183
|
+
|| !turn.proposalSet || (0, proposal_set_contract_1.validateProposalSet)(turn.proposalSet)
|
|
184
|
+
|| !Array.isArray(turn.proposalSet.proposals) || !turn.proposalSet.proposals.length
|
|
185
|
+
|| !Array.isArray(turn.decisions) || turn.decisions.length !== turn.proposalSet.proposals.length)
|
|
186
|
+
continue;
|
|
187
|
+
const proposals = turn.proposalSet.proposals;
|
|
188
|
+
if (!proposals.every((p, i) => {
|
|
189
|
+
const d = turn.decisions[i];
|
|
190
|
+
return d && d.proposalId === p.proposalId && d.revision === p.revision
|
|
191
|
+
&& ['approve', 'reject'].includes(d.action) && ['project', 'manager', 'org'].includes(d.level)
|
|
192
|
+
&& typeof d.reason === 'string';
|
|
193
|
+
}))
|
|
194
|
+
continue;
|
|
195
|
+
const indices = proposals.flatMap((p, i) => {
|
|
196
|
+
const key = JSON.stringify([p.proposalId, p.revision]);
|
|
197
|
+
if (seen.has(key))
|
|
198
|
+
return [];
|
|
199
|
+
seen.add(key);
|
|
200
|
+
return [i];
|
|
201
|
+
});
|
|
202
|
+
if (indices.length)
|
|
203
|
+
turns.push({
|
|
204
|
+
messageCount: turn.messageCount,
|
|
205
|
+
proposalSet: { ...turn.proposalSet, proposals: indices.map((i) => proposals[i]) },
|
|
206
|
+
decisions: indices.map((i) => turn.decisions[i]),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return turns;
|
|
211
|
+
}
|
|
172
212
|
// Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
|
|
173
213
|
// richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
|
|
174
214
|
function placeConversationInBucket(bucket, conv) {
|
|
@@ -196,6 +236,9 @@ function placeConversationInBucket(bucket, conv) {
|
|
|
196
236
|
&& timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
|
|
197
237
|
bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
|
|
198
238
|
}
|
|
239
|
+
const history = mergeLearningReviewTurns(existing.learningReviewTurns, conv.learningReviewTurns);
|
|
240
|
+
if (history.length)
|
|
241
|
+
bucket.conversations[idx].learningReviewTurns = history;
|
|
199
242
|
}
|
|
200
243
|
function stableConversationCreatedAt(existing, incoming) {
|
|
201
244
|
const existingValue = existing?.createdAt;
|
|
@@ -651,8 +694,18 @@ class AiHubConversationStore {
|
|
|
651
694
|
}
|
|
652
695
|
catch { /* ignore */ }
|
|
653
696
|
}
|
|
654
|
-
for (const conv of state.conversations)
|
|
697
|
+
for (const conv of state.conversations) {
|
|
698
|
+
try {
|
|
699
|
+
const prior = JSON.parse(fs_1.default.readFileSync(this.convFilePath(bucketDir, conv.id), 'utf8')).conversation;
|
|
700
|
+
if (conversationContinuityCompatible(prior, conv)) {
|
|
701
|
+
const history = mergeLearningReviewTurns(prior?.learningReviewTurns, conv.learningReviewTurns);
|
|
702
|
+
if (history.length)
|
|
703
|
+
conv.learningReviewTurns = history;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch { /* no prior copy */ }
|
|
655
707
|
this.writeConvFile(bucketDir, bucketKey, conv);
|
|
708
|
+
}
|
|
656
709
|
this.writeIndex(bucketDir, bucketKey, state.activeId, state.conversations);
|
|
657
710
|
// Issue #1065 (GDPR Art. 17 / C1): deletion must reach every copy. The whole-bucket
|
|
658
711
|
// rewrite prunes conversation shards that are no longer wanted; prune the matching
|
|
@@ -903,12 +956,19 @@ class AiHubConversationStore {
|
|
|
903
956
|
this.writeConvFile(bucketDir, key, candidate);
|
|
904
957
|
return this.reindexAfterUpsert(bucketDir, key, candidate, undefined);
|
|
905
958
|
}
|
|
959
|
+
const history = mergeLearningReviewTurns(existing.learningReviewTurns, patch.learningReviewTurns);
|
|
906
960
|
if (patch.lastUpdatedAt !== undefined && timestampValue(patch.lastUpdatedAt) < timestampValue(existing.lastUpdatedAt)) {
|
|
961
|
+
if (JSON.stringify(history) !== JSON.stringify(mergeLearningReviewTurns(existing.learningReviewTurns))) {
|
|
962
|
+
existing.learningReviewTurns = history;
|
|
963
|
+
this.writeConvFile(bucketDir, key, existing);
|
|
964
|
+
return this.reindexAfterUpsert(bucketDir, key, existing, undefined);
|
|
965
|
+
}
|
|
907
966
|
return this.stateFromIndex(bucketDir, key);
|
|
908
967
|
}
|
|
909
968
|
const merged = normalizeConversation(key, {
|
|
910
969
|
...existing,
|
|
911
970
|
...patch,
|
|
971
|
+
...(history.length ? { learningReviewTurns: history } : {}),
|
|
912
972
|
id: existing.id,
|
|
913
973
|
projectPath: key,
|
|
914
974
|
agentName: patch.agentName || existing.agentName,
|
|
@@ -5856,6 +5856,7 @@ class AiHubServer {
|
|
|
5856
5856
|
}
|
|
5857
5857
|
try {
|
|
5858
5858
|
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
|
|
5859
|
+
originalId: b.originalId,
|
|
5859
5860
|
originalTitle: b.originalTitle,
|
|
5860
5861
|
severity: b.severity,
|
|
5861
5862
|
title: b.title,
|