papergod 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/ROADMAP.md +171 -0
- package/example/main.tex +360 -0
- package/frontend/src/components/ui/badge.jsx +5 -0
- package/frontend/src/components/ui/button.jsx +24 -0
- package/frontend/src/components/workbench.jsx +182 -0
- package/frontend/src/lib/utils.js +6 -0
- package/frontend/src/main.jsx +19 -0
- package/frontend/src/theme.css +256 -0
- package/frontend/vite.config.js +23 -0
- package/package.json +73 -0
- package/papergod-demo.png +0 -0
- package/public/app.js +5480 -0
- package/public/brand/papergod-logo.png +0 -0
- package/public/i18n.js +95 -0
- package/public/index.html +480 -0
- package/public/pdf-sentence-mapping.js +142 -0
- package/public/react/app.js +209 -0
- package/public/react/assets/addon-fit-YJmn1quW.js +12 -0
- package/public/react/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/public/react/assets/main.css +32 -0
- package/public/react/assets/xterm-BqvuqXEL.js +27 -0
- package/public/style.css +1462 -0
- package/src/cli.js +128 -0
- package/src/server/agent-adapters.js +1240 -0
- package/src/server/agent-errors.js +105 -0
- package/src/server/agent-runtime.js +81 -0
- package/src/server/agent.js +173 -0
- package/src/server/app-version.js +86 -0
- package/src/server/change-history.js +114 -0
- package/src/server/document-structure.js +174 -0
- package/src/server/index.js +1442 -0
- package/src/server/latex-structure.js +344 -0
- package/src/server/latex.js +67 -0
- package/src/server/library-engine.js +193 -0
- package/src/server/library-files.js +134 -0
- package/src/server/literature-review.js +122 -0
- package/src/server/orchestration-engine.js +662 -0
- package/src/server/paragraph-analysis.js +300 -0
- package/src/server/project-resources.js +290 -0
- package/src/server/project-store.js +808 -0
- package/src/server/prompt-manifest.js +300 -0
- package/src/server/references.js +425 -0
- package/src/server/review-panel.js +263 -0
- package/src/server/revise-workflow.js +278 -0
- package/src/server/revision-engine.js +607 -0
- package/src/server/security.js +16 -0
- package/src/server/text-extraction.js +149 -0
- package/src/server/workspace-browser.js +49 -0
- package/src/server/workspace-registry.js +143 -0
- package/src/server/workspace-terminal.js +99 -0
- package/src/server/workspace.js +223 -0
- package/src/server/zotero.js +98 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { basename, join } from 'path';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'fs/promises';
|
|
4
|
+
|
|
5
|
+
export const PROJECT_SCHEMA_VERSION = 3;
|
|
6
|
+
|
|
7
|
+
const PROJECT_DIR = '.papergod';
|
|
8
|
+
const PROJECT_FILE = 'project.json';
|
|
9
|
+
const updateQueues = new Map();
|
|
10
|
+
|
|
11
|
+
function now() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createId(prefix) {
|
|
16
|
+
return `${prefix}_${randomUUID()}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDefaultProject(workspaceRoot) {
|
|
20
|
+
const timestamp = now();
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
23
|
+
project: {
|
|
24
|
+
id: createId('project'),
|
|
25
|
+
name: basename(workspaceRoot),
|
|
26
|
+
corePrompt: '',
|
|
27
|
+
createdAt: timestamp,
|
|
28
|
+
updatedAt: timestamp,
|
|
29
|
+
},
|
|
30
|
+
documents: [
|
|
31
|
+
{
|
|
32
|
+
id: createId('document'),
|
|
33
|
+
file: 'main.tex',
|
|
34
|
+
title: '',
|
|
35
|
+
summary: '',
|
|
36
|
+
corePrompt: '',
|
|
37
|
+
sections: [],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
libraries: {
|
|
41
|
+
corpora: [],
|
|
42
|
+
sentencePatterns: [],
|
|
43
|
+
vocabulary: {
|
|
44
|
+
global: [],
|
|
45
|
+
session: [],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
annotations: [],
|
|
49
|
+
reviews: [],
|
|
50
|
+
revisions: [],
|
|
51
|
+
agentRuns: [],
|
|
52
|
+
orchestrations: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function migrateProjectData(input, workspaceRoot) {
|
|
57
|
+
if (!isObject(input)) throw new Error('Project data must be an object');
|
|
58
|
+
if (input.schemaVersion === PROJECT_SCHEMA_VERSION) return { data: input, migratedFrom: null };
|
|
59
|
+
if (Number.isInteger(input.schemaVersion) && input.schemaVersion > PROJECT_SCHEMA_VERSION) {
|
|
60
|
+
throw new Error(`Project schema ${input.schemaVersion} is newer than supported schema ${PROJECT_SCHEMA_VERSION}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sourceVersion = Number.isInteger(input.schemaVersion) ? input.schemaVersion : 0;
|
|
64
|
+
if (![0, 1, 2].includes(sourceVersion)) throw new Error(`No migration path from project schema ${sourceVersion}`);
|
|
65
|
+
|
|
66
|
+
const defaults = createDefaultProject(workspaceRoot);
|
|
67
|
+
const sourceLibraries = isObject(input.libraries) ? input.libraries : {};
|
|
68
|
+
const sourceVocabulary = isObject(sourceLibraries.vocabulary) ? sourceLibraries.vocabulary : {};
|
|
69
|
+
const documents = Array.isArray(input.documents) ? input.documents.map((document) => ({
|
|
70
|
+
id: typeof document?.id === 'string' && document.id ? document.id : createId('document'),
|
|
71
|
+
file: typeof document?.file === 'string' && document.file ? document.file : 'main.tex',
|
|
72
|
+
title: typeof document?.title === 'string' ? document.title : '',
|
|
73
|
+
summary: typeof document?.summary === 'string' ? document.summary : '',
|
|
74
|
+
corePrompt: typeof document?.corePrompt === 'string' ? document.corePrompt : '',
|
|
75
|
+
sections: Array.isArray(document?.sections) ? document.sections : [],
|
|
76
|
+
})) : defaults.documents;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
migratedFrom: sourceVersion,
|
|
80
|
+
data: {
|
|
81
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
82
|
+
project: { ...defaults.project, ...(isObject(input.project) ? input.project : {}) },
|
|
83
|
+
documents,
|
|
84
|
+
libraries: {
|
|
85
|
+
corpora: Array.isArray(sourceLibraries.corpora) ? sourceLibraries.corpora : [],
|
|
86
|
+
sentencePatterns: Array.isArray(sourceLibraries.sentencePatterns) ? sourceLibraries.sentencePatterns : [],
|
|
87
|
+
vocabulary: {
|
|
88
|
+
global: Array.isArray(sourceVocabulary.global) ? sourceVocabulary.global : [],
|
|
89
|
+
session: Array.isArray(sourceVocabulary.session) ? sourceVocabulary.session : [],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
annotations: Array.isArray(input.annotations) ? input.annotations : [],
|
|
93
|
+
reviews: Array.isArray(input.reviews) ? input.reviews.map((review) => migrateReview(review)) : [],
|
|
94
|
+
revisions: Array.isArray(input.revisions) ? input.revisions : [],
|
|
95
|
+
agentRuns: Array.isArray(input.agentRuns) ? input.agentRuns : [],
|
|
96
|
+
orchestrations: Array.isArray(input.orchestrations) ? input.orchestrations : [],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function migrateReview(review = {}) {
|
|
102
|
+
const timestamp = now();
|
|
103
|
+
const reviewers = Array.isArray(review.reviewers) ? review.reviewers.map((reviewer, index) => {
|
|
104
|
+
if (isObject(reviewer)) return {
|
|
105
|
+
id: typeof reviewer.id === 'string' && reviewer.id ? reviewer.id : createId('reviewer'),
|
|
106
|
+
name: typeof reviewer.name === 'string' && reviewer.name ? reviewer.name : `Legacy reviewer ${index + 1}`,
|
|
107
|
+
role: ['methodology', 'statistics', 'writing', 'domain', 'reproducibility'].includes(reviewer.role) ? reviewer.role : 'domain',
|
|
108
|
+
focus: typeof reviewer.focus === 'string' && reviewer.focus ? reviewer.focus : 'General academic quality and correctness.',
|
|
109
|
+
prompt: typeof reviewer.prompt === 'string' ? reviewer.prompt : '',
|
|
110
|
+
};
|
|
111
|
+
return { id: createId('reviewer'), name: String(reviewer || `Legacy reviewer ${index + 1}`), role: 'domain', focus: 'General academic quality and correctness.', prompt: '' };
|
|
112
|
+
}) : [];
|
|
113
|
+
if (!reviewers.length) reviewers.push({ id: createId('reviewer'), name: 'Legacy reviewer', role: 'domain', focus: 'General academic quality and correctness.', prompt: '' });
|
|
114
|
+
const rubric = Array.isArray(review.rubric) && review.rubric.length ? review.rubric : [
|
|
115
|
+
{ id: 'general', title: 'General quality', instruction: 'Assess correctness, evidence, clarity, and significance.', weight: 1 },
|
|
116
|
+
];
|
|
117
|
+
const items = Array.isArray(review.items) ? review.items.map((item) => ({
|
|
118
|
+
id: typeof item?.id === 'string' && item.id ? item.id : createId('review_item'),
|
|
119
|
+
reviewerId: typeof item?.reviewerId === 'string' && item.reviewerId ? item.reviewerId : reviewers[0].id,
|
|
120
|
+
rubricId: typeof item?.rubricId === 'string' && item.rubricId ? item.rubricId : rubric[0].id,
|
|
121
|
+
kind: item?.kind === 'strength' ? 'strength' : 'concern',
|
|
122
|
+
category: ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'].includes(item?.category) ? item.category : 'other',
|
|
123
|
+
severity: ['info', 'minor', 'major', 'critical'].includes(item?.severity) ? item.severity : 'info',
|
|
124
|
+
body: typeof item?.body === 'string' && item.body ? item.body : typeof item === 'string' ? item : 'Legacy review finding',
|
|
125
|
+
suggestedFix: typeof item?.suggestedFix === 'string' ? item.suggestedFix : '',
|
|
126
|
+
quote: typeof item?.quote === 'string' ? item.quote : '',
|
|
127
|
+
})) : [];
|
|
128
|
+
return {
|
|
129
|
+
...review,
|
|
130
|
+
id: typeof review.id === 'string' && review.id ? review.id : createId('review'),
|
|
131
|
+
documentId: typeof review.documentId === 'string' ? review.documentId : '',
|
|
132
|
+
name: typeof review.name === 'string' && review.name ? review.name : 'Migrated review round',
|
|
133
|
+
status: ['draft', 'running', 'complete', 'failed'].includes(review.status) ? review.status : 'draft',
|
|
134
|
+
provider: ['mock', 'codex', 'claude-code', 'opencode', 'pi'].includes(review.provider) ? review.provider : 'mock',
|
|
135
|
+
reviewers, rubric, reports: Array.isArray(review.reports) ? review.reports : [], items,
|
|
136
|
+
synthesis: isObject(review.synthesis) ? review.synthesis : { summary: '', verdict: '', consensus: [], conflicts: [], priorities: [] },
|
|
137
|
+
createdAt: typeof review.createdAt === 'string' && review.createdAt ? review.createdAt : timestamp,
|
|
138
|
+
updatedAt: typeof review.updatedAt === 'string' && review.updatedAt ? review.updatedAt : timestamp,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isObject(value) {
|
|
143
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateString(value, path, errors, { allowEmpty = true } = {}) {
|
|
147
|
+
if (typeof value !== 'string' || (!allowEmpty && value.trim() === '')) {
|
|
148
|
+
errors.push(`${path} must be ${allowEmpty ? 'a string' : 'a non-empty string'}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateStringArray(value, path, errors) {
|
|
153
|
+
if (!Array.isArray(value)) {
|
|
154
|
+
errors.push(`${path} must be an array`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
value.forEach((item, index) => validateString(item, `${path}[${index}]`, errors, { allowEmpty: false }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateEnum(value, allowed, path, errors) {
|
|
161
|
+
if (!allowed.includes(value)) errors.push(`${path} must be one of: ${allowed.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateRecordBase(record, path, errors) {
|
|
165
|
+
if (!isObject(record)) {
|
|
166
|
+
errors.push(`${path} must be an object`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
validateString(record.id, `${path}.id`, errors, { allowEmpty: false });
|
|
170
|
+
validateString(record.createdAt, `${path}.createdAt`, errors, { allowEmpty: false });
|
|
171
|
+
validateString(record.updatedAt, `${path}.updatedAt`, errors, { allowEmpty: false });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateCorpus(item, path, errors) {
|
|
176
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
177
|
+
validateString(item.name, `${path}.name`, errors, { allowEmpty: false });
|
|
178
|
+
validateString(item.description, `${path}.description`, errors);
|
|
179
|
+
validateString(item.content, `${path}.content`, errors, { allowEmpty: false });
|
|
180
|
+
validateString(item.source, `${path}.source`, errors);
|
|
181
|
+
validateStringArray(item.tags, `${path}.tags`, errors);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateSentencePattern(item, path, errors) {
|
|
185
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
186
|
+
validateString(item.name, `${path}.name`, errors, { allowEmpty: false });
|
|
187
|
+
validateString(item.template, `${path}.template`, errors, { allowEmpty: false });
|
|
188
|
+
validateString(item.description, `${path}.description`, errors);
|
|
189
|
+
validateString(item.source, `${path}.source`, errors);
|
|
190
|
+
validateStringArray(item.tags, `${path}.tags`, errors);
|
|
191
|
+
validateStringArray(item.sectionTypes, `${path}.sectionTypes`, errors);
|
|
192
|
+
if (!Array.isArray(item.slots)) {
|
|
193
|
+
errors.push(`${path}.slots must be an array`);
|
|
194
|
+
} else {
|
|
195
|
+
item.slots.forEach((slot, index) => {
|
|
196
|
+
const slotPath = `${path}.slots[${index}]`;
|
|
197
|
+
if (!isObject(slot)) return errors.push(`${slotPath} must be an object`);
|
|
198
|
+
validateString(slot.name, `${slotPath}.name`, errors, { allowEmpty: false });
|
|
199
|
+
validateString(slot.description, `${slotPath}.description`, errors);
|
|
200
|
+
if (typeof slot.required !== 'boolean') errors.push(`${slotPath}.required must be a boolean`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateVocabulary(item, path, errors) {
|
|
206
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
207
|
+
validateString(item.term, `${path}.term`, errors, { allowEmpty: false });
|
|
208
|
+
validateString(item.preferred, `${path}.preferred`, errors);
|
|
209
|
+
validateString(item.definition, `${path}.definition`, errors);
|
|
210
|
+
validateString(item.source, `${path}.source`, errors);
|
|
211
|
+
validateStringArray(item.alternatives, `${path}.alternatives`, errors);
|
|
212
|
+
validateStringArray(item.examples, `${path}.examples`, errors);
|
|
213
|
+
validateStringArray(item.tags, `${path}.tags`, errors);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateTarget(target, path, errors) {
|
|
217
|
+
if (!isObject(target)) return errors.push(`${path} must be an object`);
|
|
218
|
+
validateEnum(target.type, ['document', 'section', 'paragraph', 'sentence', 'range'], `${path}.type`, errors);
|
|
219
|
+
validateString(target.id, `${path}.id`, errors);
|
|
220
|
+
validateString(target.quote, `${path}.quote`, errors);
|
|
221
|
+
for (const field of ['start', 'end']) {
|
|
222
|
+
if (!Number.isInteger(target[field]) || target[field] < 0) errors.push(`${path}.${field} must be a non-negative integer`);
|
|
223
|
+
}
|
|
224
|
+
if (Number.isInteger(target.start) && Number.isInteger(target.end) && target.end < target.start) {
|
|
225
|
+
errors.push(`${path}.end must be greater than or equal to start`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function validateAnnotation(item, path, errors) {
|
|
230
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
231
|
+
validateString(item.documentId, `${path}.documentId`, errors, { allowEmpty: false });
|
|
232
|
+
validateTarget(item.target, `${path}.target`, errors);
|
|
233
|
+
validateEnum(item.category, ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'], `${path}.category`, errors);
|
|
234
|
+
validateEnum(item.severity, ['info', 'minor', 'major', 'critical'], `${path}.severity`, errors);
|
|
235
|
+
validateString(item.body, `${path}.body`, errors, { allowEmpty: false });
|
|
236
|
+
validateString(item.suggestedFix, `${path}.suggestedFix`, errors);
|
|
237
|
+
validateEnum(item.status, ['open', 'planned', 'resolved', 'rejected', 'deferred'], `${path}.status`, errors);
|
|
238
|
+
if (item.order !== undefined && (!Number.isInteger(item.order) || item.order < 0)) errors.push(`${path}.order must be a non-negative integer`);
|
|
239
|
+
if (item.dependsOn !== undefined) validateStringArray(item.dependsOn, `${path}.dependsOn`, errors);
|
|
240
|
+
if (!isObject(item.source)) {
|
|
241
|
+
errors.push(`${path}.source must be an object`);
|
|
242
|
+
} else {
|
|
243
|
+
validateEnum(item.source.type, ['user', 'agent', 'reviewer', 'import'], `${path}.source.type`, errors);
|
|
244
|
+
validateString(item.source.actor, `${path}.source.actor`, errors);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateChange(item, path, errors) {
|
|
249
|
+
if (!isObject(item)) return errors.push(`${path} must be an object`);
|
|
250
|
+
validateString(item.id, `${path}.id`, errors, { allowEmpty: false });
|
|
251
|
+
validateTarget(item.target, `${path}.target`, errors);
|
|
252
|
+
validateString(item.before, `${path}.before`, errors);
|
|
253
|
+
validateString(item.after, `${path}.after`, errors);
|
|
254
|
+
validateString(item.reason, `${path}.reason`, errors);
|
|
255
|
+
validateEnum(item.status, ['proposed', 'accepted', 'rejected', 'deferred', 'applied', 'reverted'], `${path}.status`, errors);
|
|
256
|
+
if (item.annotationId !== undefined) validateString(item.annotationId, `${path}.annotationId`, errors, { allowEmpty: false });
|
|
257
|
+
if (item.executable !== undefined && typeof item.executable !== 'boolean') errors.push(`${path}.executable must be a boolean`);
|
|
258
|
+
if (item.dependsOn !== undefined) validateStringArray(item.dependsOn, `${path}.dependsOn`, errors);
|
|
259
|
+
if (item.conflictsWith !== undefined) validateStringArray(item.conflictsWith, `${path}.conflictsWith`, errors);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateRevision(item, path, errors) {
|
|
263
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
264
|
+
validateString(item.documentId, `${path}.documentId`, errors, { allowEmpty: false });
|
|
265
|
+
validateString(item.title, `${path}.title`, errors, { allowEmpty: false });
|
|
266
|
+
validateString(item.summary, `${path}.summary`, errors);
|
|
267
|
+
validateEnum(item.status, ['draft', 'planned', 'running', 'review', 'applied', 'rolled-back', 'cancelled', 'failed'], `${path}.status`, errors);
|
|
268
|
+
if (item.file !== undefined) validateString(item.file, `${path}.file`, errors, { allowEmpty: false });
|
|
269
|
+
validateStringArray(item.annotationIds, `${path}.annotationIds`, errors);
|
|
270
|
+
if (!Array.isArray(item.changes)) {
|
|
271
|
+
errors.push(`${path}.changes must be an array`);
|
|
272
|
+
} else {
|
|
273
|
+
item.changes.forEach((change, index) => validateChange(change, `${path}.changes[${index}]`, errors));
|
|
274
|
+
}
|
|
275
|
+
if (item.graph !== undefined) {
|
|
276
|
+
if (!isObject(item.graph) || !Array.isArray(item.graph.nodes) || !Array.isArray(item.graph.edges)) {
|
|
277
|
+
errors.push(`${path}.graph must contain nodes and edges arrays`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (item.recoveryPoint !== undefined && item.recoveryPoint !== null) {
|
|
281
|
+
if (!isObject(item.recoveryPoint)) errors.push(`${path}.recoveryPoint must be an object or null`);
|
|
282
|
+
else {
|
|
283
|
+
for (const field of ['id', 'file', 'path', 'sourceHash', 'appliedHash', 'createdAt']) {
|
|
284
|
+
validateString(item.recoveryPoint[field], `${path}.recoveryPoint.${field}`, errors, { allowEmpty: false });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (item.appliedAt !== undefined) validateString(item.appliedAt, `${path}.appliedAt`, errors, { allowEmpty: false });
|
|
289
|
+
if (item.rolledBackAt !== undefined) validateString(item.rolledBackAt, `${path}.rolledBackAt`, errors, { allowEmpty: false });
|
|
290
|
+
if (item.origin !== undefined) validateEnum(item.origin, ['paper-generation', 'paragraph-generation', 'agent-suggestion', 'agent-batch', 'history-restore'], `${path}.origin`, errors);
|
|
291
|
+
if (item.generation !== undefined) {
|
|
292
|
+
if (!isObject(item.generation)) errors.push(`${path}.generation must be an object`);
|
|
293
|
+
else {
|
|
294
|
+
validateString(item.generation.runId, `${path}.generation.runId`, errors, { allowEmpty: false });
|
|
295
|
+
validateString(item.generation.instruction, `${path}.generation.instruction`, errors);
|
|
296
|
+
validateStringArray(item.generation.providedResourceIds, `${path}.generation.providedResourceIds`, errors);
|
|
297
|
+
validateStringArray(item.generation.usedResourceIds, `${path}.generation.usedResourceIds`, errors);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (item.responseLetter !== undefined) {
|
|
301
|
+
if (!isObject(item.responseLetter)) errors.push(`${path}.responseLetter must be an object`);
|
|
302
|
+
else {
|
|
303
|
+
validateString(item.responseLetter.title, `${path}.responseLetter.title`, errors, { allowEmpty: false });
|
|
304
|
+
validateString(item.responseLetter.introduction, `${path}.responseLetter.introduction`, errors);
|
|
305
|
+
if (!Array.isArray(item.responseLetter.items)) errors.push(`${path}.responseLetter.items must be an array`);
|
|
306
|
+
else item.responseLetter.items.forEach((response, index) => {
|
|
307
|
+
const responsePath = `${path}.responseLetter.items[${index}]`;
|
|
308
|
+
if (!isObject(response)) return errors.push(`${responsePath} must be an object`);
|
|
309
|
+
for (const field of ['annotationId', 'opinion', 'response', 'status', 'location']) validateString(response[field], `${responsePath}.${field}`, errors, { allowEmpty: field === 'location' });
|
|
310
|
+
if (!Number.isInteger(response.order) || response.order < 0) errors.push(`${responsePath}.order must be a non-negative integer`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (item.changeList !== undefined) {
|
|
315
|
+
if (!Array.isArray(item.changeList)) errors.push(`${path}.changeList must be an array`);
|
|
316
|
+
else item.changeList.forEach((change, index) => {
|
|
317
|
+
const changePath = `${path}.changeList[${index}]`;
|
|
318
|
+
if (!isObject(change)) return errors.push(`${changePath} must be an object`);
|
|
319
|
+
for (const field of ['changeId', 'annotationId', 'status', 'location', 'before', 'after', 'reason']) validateString(change[field], `${changePath}.${field}`, errors, { allowEmpty: field !== 'changeId' });
|
|
320
|
+
if (!Number.isInteger(change.order) || change.order < 0) errors.push(`${changePath}.order must be a non-negative integer`);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (item.verification !== undefined) {
|
|
324
|
+
if (!isObject(item.verification) || !isObject(item.verification.compile)) errors.push(`${path}.verification must contain compile`);
|
|
325
|
+
else {
|
|
326
|
+
validateString(item.verification.checkedAt, `${path}.verification.checkedAt`, errors, { allowEmpty: false });
|
|
327
|
+
if (typeof item.verification.compile.ok !== 'boolean') errors.push(`${path}.verification.compile.ok must be a boolean`);
|
|
328
|
+
validateString(item.verification.compile.engine, `${path}.verification.compile.engine`, errors);
|
|
329
|
+
validateString(item.verification.compile.error, `${path}.verification.compile.error`, errors);
|
|
330
|
+
validateStringArray(item.verification.unresolvedAnnotationIds, `${path}.verification.unresolvedAnnotationIds`, errors);
|
|
331
|
+
if (typeof item.verification.complete !== 'boolean') errors.push(`${path}.verification.complete must be a boolean`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validateReview(item, path, errors) {
|
|
337
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
338
|
+
validateString(item.documentId, `${path}.documentId`, errors, { allowEmpty: false });
|
|
339
|
+
validateString(item.name, `${path}.name`, errors, { allowEmpty: false });
|
|
340
|
+
validateEnum(item.status, ['draft', 'running', 'complete', 'failed'], `${path}.status`, errors);
|
|
341
|
+
validateEnum(item.provider, ['mock', 'codex', 'claude-code', 'opencode', 'pi'], `${path}.provider`, errors);
|
|
342
|
+
if (!Array.isArray(item.reviewers)) errors.push(`${path}.reviewers must be an array`);
|
|
343
|
+
else item.reviewers.forEach((reviewer, index) => {
|
|
344
|
+
const reviewerPath = `${path}.reviewers[${index}]`;
|
|
345
|
+
if (!isObject(reviewer)) return errors.push(`${reviewerPath} must be an object`);
|
|
346
|
+
for (const field of ['id', 'name', 'role', 'focus']) validateString(reviewer[field], `${reviewerPath}.${field}`, errors, { allowEmpty: false });
|
|
347
|
+
validateString(reviewer.prompt, `${reviewerPath}.prompt`, errors);
|
|
348
|
+
validateEnum(reviewer.role, ['methodology', 'statistics', 'writing', 'domain', 'reproducibility'], `${reviewerPath}.role`, errors);
|
|
349
|
+
});
|
|
350
|
+
if (!Array.isArray(item.rubric)) errors.push(`${path}.rubric must be an array`);
|
|
351
|
+
else item.rubric.forEach((criterion, index) => {
|
|
352
|
+
const criterionPath = `${path}.rubric[${index}]`;
|
|
353
|
+
if (!isObject(criterion)) return errors.push(`${criterionPath} must be an object`);
|
|
354
|
+
for (const field of ['id', 'title', 'instruction']) validateString(criterion[field], `${criterionPath}.${field}`, errors, { allowEmpty: false });
|
|
355
|
+
if (!Number.isFinite(criterion.weight) || criterion.weight <= 0) errors.push(`${criterionPath}.weight must be a positive number`);
|
|
356
|
+
});
|
|
357
|
+
const validateReviewItem = (reviewItem, itemPath) => {
|
|
358
|
+
if (!isObject(reviewItem)) return errors.push(`${itemPath} must be an object`);
|
|
359
|
+
for (const field of ['id', 'reviewerId', 'rubricId', 'body']) validateString(reviewItem[field], `${itemPath}.${field}`, errors, { allowEmpty: false });
|
|
360
|
+
validateEnum(reviewItem.kind, ['concern', 'strength'], `${itemPath}.kind`, errors);
|
|
361
|
+
validateEnum(reviewItem.category, ['content', 'structure', 'method', 'evidence', 'style', 'grammar', 'citation', 'other'], `${itemPath}.category`, errors);
|
|
362
|
+
validateEnum(reviewItem.severity, ['info', 'minor', 'major', 'critical'], `${itemPath}.severity`, errors);
|
|
363
|
+
validateString(reviewItem.suggestedFix, `${itemPath}.suggestedFix`, errors);
|
|
364
|
+
validateString(reviewItem.quote, `${itemPath}.quote`, errors);
|
|
365
|
+
};
|
|
366
|
+
if (!Array.isArray(item.reports)) errors.push(`${path}.reports must be an array`);
|
|
367
|
+
else item.reports.forEach((report, index) => {
|
|
368
|
+
const reportPath = `${path}.reports[${index}]`;
|
|
369
|
+
if (!isObject(report)) return errors.push(`${reportPath} must be an object`);
|
|
370
|
+
for (const field of ['id', 'reviewerId', 'runId', 'createdAt']) validateString(report[field], `${reportPath}.${field}`, errors, { allowEmpty: false });
|
|
371
|
+
validateEnum(report.status, ['complete', 'failed'], `${reportPath}.status`, errors);
|
|
372
|
+
validateString(report.summary, `${reportPath}.summary`, errors);
|
|
373
|
+
validateEnum(report.verdict, ['accept', 'minor-revision', 'major-revision', 'reject'], `${reportPath}.verdict`, errors);
|
|
374
|
+
if (typeof report.confidence !== 'number' || report.confidence < 0 || report.confidence > 1) errors.push(`${reportPath}.confidence must be between 0 and 1`);
|
|
375
|
+
validateString(report.error, `${reportPath}.error`, errors);
|
|
376
|
+
if (!Array.isArray(report.items)) errors.push(`${reportPath}.items must be an array`);
|
|
377
|
+
else report.items.forEach((reviewItem, itemIndex) => validateReviewItem(reviewItem, `${reportPath}.items[${itemIndex}]`));
|
|
378
|
+
});
|
|
379
|
+
if (!Array.isArray(item.items)) errors.push(`${path}.items must be an array`);
|
|
380
|
+
else item.items.forEach((reviewItem, index) => validateReviewItem(reviewItem, `${path}.items[${index}]`));
|
|
381
|
+
if (!isObject(item.synthesis)) errors.push(`${path}.synthesis must be an object`);
|
|
382
|
+
else {
|
|
383
|
+
validateString(item.synthesis.summary, `${path}.synthesis.summary`, errors);
|
|
384
|
+
validateString(item.synthesis.verdict, `${path}.synthesis.verdict`, errors);
|
|
385
|
+
for (const field of ['consensus', 'conflicts', 'priorities']) {
|
|
386
|
+
if (!Array.isArray(item.synthesis[field])) errors.push(`${path}.synthesis.${field} must be an array`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function validateAgentRun(item, path, errors) {
|
|
392
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
393
|
+
validateEnum(item.provider, ['mock', 'codex', 'claude-code', 'opencode', 'pi'], `${path}.provider`, errors);
|
|
394
|
+
validateString(item.operation, `${path}.operation`, errors, { allowEmpty: false });
|
|
395
|
+
validateEnum(item.status, ['queued', 'running', 'complete', 'failed', 'cancelled'], `${path}.status`, errors);
|
|
396
|
+
validateString(item.prompt, `${path}.prompt`, errors);
|
|
397
|
+
validateString(item.input, `${path}.input`, errors);
|
|
398
|
+
validateString(item.output, `${path}.output`, errors);
|
|
399
|
+
validateString(item.error, `${path}.error`, errors);
|
|
400
|
+
validateString(item.startedAt, `${path}.startedAt`, errors);
|
|
401
|
+
validateString(item.finishedAt, `${path}.finishedAt`, errors);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const ORCHESTRATION_NODE_KINDS = ['agent', 'gate'];
|
|
405
|
+
const ORCHESTRATION_NODE_STATUSES = ['idle', 'queued', 'running', 'complete', 'failed', 'waiting', 'skipped'];
|
|
406
|
+
const ORCHESTRATION_CAPABILITIES = ['suggest', 'review', 'paragraph', 'generate'];
|
|
407
|
+
const ORCHESTRATION_PROVIDERS = ['mock', 'codex', 'claude-code', 'opencode', 'pi'];
|
|
408
|
+
const ORCHESTRATION_REVIEW_ROLES = ['methodology', 'statistics', 'writing', 'domain', 'reproducibility'];
|
|
409
|
+
|
|
410
|
+
function validateOrchestrationNode(node, path, errors) {
|
|
411
|
+
if (!isObject(node)) return errors.push(`${path} must be an object`);
|
|
412
|
+
validateString(node.id, `${path}.id`, errors, { allowEmpty: false });
|
|
413
|
+
validateEnum(node.kind, ORCHESTRATION_NODE_KINDS, `${path}.kind`, errors);
|
|
414
|
+
validateString(node.label, `${path}.label`, errors);
|
|
415
|
+
if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) errors.push(`${path}.x and y must be numbers`);
|
|
416
|
+
validateString(node.prompt, `${path}.prompt`, errors);
|
|
417
|
+
validateEnum(node.status, ORCHESTRATION_NODE_STATUSES, `${path}.status`, errors);
|
|
418
|
+
validateString(node.runId, `${path}.runId`, errors);
|
|
419
|
+
validateString(node.error, `${path}.error`, errors);
|
|
420
|
+
validateString(node.startedAt, `${path}.startedAt`, errors);
|
|
421
|
+
validateString(node.finishedAt, `${path}.finishedAt`, errors);
|
|
422
|
+
if (node.kind === 'agent') {
|
|
423
|
+
validateEnum(node.provider, ORCHESTRATION_PROVIDERS, `${path}.provider`, errors);
|
|
424
|
+
validateEnum(node.capability, ORCHESTRATION_CAPABILITIES, `${path}.capability`, errors);
|
|
425
|
+
}
|
|
426
|
+
if (node.kind === 'gate') {
|
|
427
|
+
if (node.decision !== undefined) validateEnum(node.decision, ['pending', 'approved', 'rejected'], `${path}.decision`, errors);
|
|
428
|
+
validateString(node.note, `${path}.note`, errors);
|
|
429
|
+
}
|
|
430
|
+
if (node.source !== undefined) {
|
|
431
|
+
if (!isObject(node.source)) errors.push(`${path}.source must be an object`);
|
|
432
|
+
else {
|
|
433
|
+
validateEnum(node.source.type, ['manual', 'upstream'], `${path}.source.type`, errors);
|
|
434
|
+
validateString(node.source.nodeId, `${path}.source.nodeId`, errors);
|
|
435
|
+
validateString(node.source.text, `${path}.source.text`, errors);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (node.output !== undefined && node.output !== null) {
|
|
439
|
+
if (!isObject(node.output)) errors.push(`${path}.output must be an object or null`);
|
|
440
|
+
else {
|
|
441
|
+
validateString(node.output.summary, `${path}.output.summary`, errors);
|
|
442
|
+
validateString(node.output.data, `${path}.output.data`, errors);
|
|
443
|
+
validateString(node.output.contentType, `${path}.output.contentType`, errors);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (node.reviewer !== undefined && node.reviewer !== null) {
|
|
447
|
+
if (!isObject(node.reviewer)) errors.push(`${path}.reviewer must be an object or null`);
|
|
448
|
+
else {
|
|
449
|
+
for (const field of ['name', 'focus', 'prompt']) validateString(node.reviewer[field], `${path}.reviewer.${field}`, errors);
|
|
450
|
+
validateEnum(node.reviewer.role, ORCHESTRATION_REVIEW_ROLES, `${path}.reviewer.role`, errors);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (node.rubric !== undefined) {
|
|
454
|
+
if (!Array.isArray(node.rubric)) errors.push(`${path}.rubric must be an array`);
|
|
455
|
+
else node.rubric.forEach((criterion, index) => {
|
|
456
|
+
const criterionPath = `${path}.rubric[${index}]`;
|
|
457
|
+
if (!isObject(criterion)) return errors.push(`${criterionPath} must be an object`);
|
|
458
|
+
for (const field of ['id', 'title', 'instruction']) validateString(criterion[field], `${criterionPath}.${field}`, errors, { allowEmpty: false });
|
|
459
|
+
if (!Number.isFinite(criterion.weight) || criterion.weight <= 0) errors.push(`${criterionPath}.weight must be a positive number`);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function validateOrchestration(item, path, errors) {
|
|
465
|
+
if (!validateRecordBase(item, path, errors)) return;
|
|
466
|
+
validateString(item.name, `${path}.name`, errors, { allowEmpty: false });
|
|
467
|
+
validateEnum(item.status, ['draft', 'running', 'complete', 'failed', 'cancelled'], `${path}.status`, errors);
|
|
468
|
+
const nodeIds = new Set();
|
|
469
|
+
if (!Array.isArray(item.nodes)) {
|
|
470
|
+
errors.push(`${path}.nodes must be an array`);
|
|
471
|
+
} else if (item.nodes.length > 50) {
|
|
472
|
+
errors.push(`${path}.nodes must contain at most 50 nodes`);
|
|
473
|
+
} else {
|
|
474
|
+
item.nodes.forEach((node, index) => {
|
|
475
|
+
const nodePath = `${path}.nodes[${index}]`;
|
|
476
|
+
validateOrchestrationNode(node, nodePath, errors);
|
|
477
|
+
if (typeof node?.id === 'string' && node.id) {
|
|
478
|
+
if (nodeIds.has(node.id)) errors.push(`${nodePath}.id is duplicated`);
|
|
479
|
+
nodeIds.add(node.id);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
item.nodes.forEach((node, index) => {
|
|
483
|
+
if (node?.source?.type === 'upstream' && node.source.nodeId && !nodeIds.has(node.source.nodeId)) {
|
|
484
|
+
errors.push(`${path}.nodes[${index}].source.nodeId does not reference a node`);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (!Array.isArray(item.edges)) {
|
|
489
|
+
errors.push(`${path}.edges must be an array`);
|
|
490
|
+
} else if (item.edges.length > 200) {
|
|
491
|
+
errors.push(`${path}.edges must contain at most 200 edges`);
|
|
492
|
+
} else {
|
|
493
|
+
const pairs = new Set();
|
|
494
|
+
item.edges.forEach((edge, index) => {
|
|
495
|
+
const edgePath = `${path}.edges[${index}]`;
|
|
496
|
+
if (!isObject(edge)) return errors.push(`${edgePath} must be an object`);
|
|
497
|
+
validateString(edge.id, `${edgePath}.id`, errors, { allowEmpty: false });
|
|
498
|
+
validateString(edge.source, `${edgePath}.source`, errors, { allowEmpty: false });
|
|
499
|
+
validateString(edge.target, `${edgePath}.target`, errors, { allowEmpty: false });
|
|
500
|
+
validateString(edge.summary, `${edgePath}.summary`, errors);
|
|
501
|
+
if (edge.source !== 'start' && !nodeIds.has(edge.source)) errors.push(`${edgePath}.source does not reference a node`);
|
|
502
|
+
if (edge.target !== 'end' && !nodeIds.has(edge.target)) errors.push(`${edgePath}.target does not reference a node`);
|
|
503
|
+
if (edge.target === 'start') errors.push(`${edgePath}.target cannot be the start`);
|
|
504
|
+
if (edge.source === 'end') errors.push(`${edgePath}.source cannot be the end`);
|
|
505
|
+
if (edge.source === edge.target && edge.source !== 'start' && edge.source !== 'end') errors.push(`${edgePath} cannot be a self loop`);
|
|
506
|
+
const pair = `${edge.source}->${edge.target}`;
|
|
507
|
+
if (pairs.has(pair)) errors.push(`${edgePath} duplicates an existing edge`);
|
|
508
|
+
pairs.add(pair);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function validateNode(node, type, path, errors) {
|
|
514
|
+
if (!isObject(node)) {
|
|
515
|
+
errors.push(`${path} must be an object`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
validateString(node.id, `${path}.id`, errors, { allowEmpty: false });
|
|
519
|
+
if (node.type !== type) errors.push(`${path}.type must be "${type}"`);
|
|
520
|
+
validateString(node.parentId, `${path}.parentId`, errors);
|
|
521
|
+
if (!Number.isFinite(node.order)) errors.push(`${path}.order must be a number`);
|
|
522
|
+
validateString(node.text, `${path}.text`, errors);
|
|
523
|
+
validateString(node.prompt, `${path}.prompt`, errors);
|
|
524
|
+
validateString(node.summary, `${path}.summary`, errors);
|
|
525
|
+
if (node.sourceRange !== undefined) {
|
|
526
|
+
if (!isObject(node.sourceRange)) errors.push(`${path}.sourceRange must be an object`);
|
|
527
|
+
else {
|
|
528
|
+
for (const field of ['start', 'end', 'contentStart', 'contentEnd']) {
|
|
529
|
+
if (!Number.isInteger(node.sourceRange[field]) || node.sourceRange[field] < 0) {
|
|
530
|
+
errors.push(`${path}.sourceRange.${field} must be a non-negative integer`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (node.sourceRange.end < node.sourceRange.start) errors.push(`${path}.sourceRange.end must be greater than or equal to start`);
|
|
534
|
+
if (node.sourceRange.contentStart < node.sourceRange.start || node.sourceRange.contentEnd > node.sourceRange.end) {
|
|
535
|
+
errors.push(`${path}.sourceRange content must be contained in the node range`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (type === 'sentence') validateString(node.intent, `${path}.intent`, errors);
|
|
540
|
+
if (type !== 'sentence') {
|
|
541
|
+
if (!Array.isArray(node.children)) {
|
|
542
|
+
errors.push(`${path}.children must be an array`);
|
|
543
|
+
} else {
|
|
544
|
+
const childType = type === 'section' ? 'paragraph' : 'sentence';
|
|
545
|
+
node.children.forEach((child, index) => validateNode(child, childType, `${path}.children[${index}]`, errors));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function collectNodeIds(nodes, ids, errors) {
|
|
551
|
+
for (const node of nodes) {
|
|
552
|
+
if (typeof node?.id === 'string') {
|
|
553
|
+
if (ids.has(node.id)) errors.push(`duplicate id: ${node.id}`);
|
|
554
|
+
ids.add(node.id);
|
|
555
|
+
}
|
|
556
|
+
if (Array.isArray(node?.children)) collectNodeIds(node.children, ids, errors);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function validateProject(data) {
|
|
561
|
+
const errors = [];
|
|
562
|
+
if (!isObject(data)) return { ok: false, errors: ['project data must be an object'] };
|
|
563
|
+
if (data.schemaVersion !== PROJECT_SCHEMA_VERSION) {
|
|
564
|
+
errors.push(`schemaVersion must be ${PROJECT_SCHEMA_VERSION}`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!isObject(data.project)) {
|
|
568
|
+
errors.push('project must be an object');
|
|
569
|
+
} else {
|
|
570
|
+
validateString(data.project.id, 'project.id', errors, { allowEmpty: false });
|
|
571
|
+
validateString(data.project.name, 'project.name', errors, { allowEmpty: false });
|
|
572
|
+
validateString(data.project.corePrompt, 'project.corePrompt', errors);
|
|
573
|
+
if (data.project.activeAgentProvider !== undefined) validateEnum(data.project.activeAgentProvider, ['mock', 'codex', 'claude-code', 'opencode', 'pi'], 'project.activeAgentProvider', errors);
|
|
574
|
+
if (data.project.agentProfiles !== undefined) {
|
|
575
|
+
if (!isObject(data.project.agentProfiles)) errors.push('project.agentProfiles must be an object');
|
|
576
|
+
else for (const [provider, profile] of Object.entries(data.project.agentProfiles)) {
|
|
577
|
+
const path = `project.agentProfiles.${provider}`;
|
|
578
|
+
if (!['mock', 'codex', 'claude-code', 'opencode', 'pi'].includes(provider)) errors.push(`${path} uses an unknown provider`);
|
|
579
|
+
if (!isObject(profile)) { errors.push(`${path} must be an object`); continue; }
|
|
580
|
+
if (profile.command !== undefined && (typeof profile.command !== 'string' || profile.command.length > 500)) errors.push(`${path}.command must be a string up to 500 characters`);
|
|
581
|
+
if (profile.model !== undefined && (typeof profile.model !== 'string' || profile.model.length > 200)) errors.push(`${path}.model must be a string up to 200 characters`);
|
|
582
|
+
if (profile.reasoningEffort !== undefined && !['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(profile.reasoningEffort)) errors.push(`${path}.reasoningEffort is invalid`);
|
|
583
|
+
if (profile.args !== undefined && (!Array.isArray(profile.args) || profile.args.length > 30 || profile.args.some((item) => typeof item !== 'string'))) errors.push(`${path}.args must contain up to 30 strings`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
validateString(data.project.createdAt, 'project.createdAt', errors, { allowEmpty: false });
|
|
587
|
+
validateString(data.project.updatedAt, 'project.updatedAt', errors, { allowEmpty: false });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const documentIds = new Set();
|
|
591
|
+
const targetIds = new Set();
|
|
592
|
+
const allIds = new Set();
|
|
593
|
+
const registerId = (value, path) => {
|
|
594
|
+
if (typeof value !== 'string' || !value) return;
|
|
595
|
+
if (allIds.has(value)) errors.push(`duplicate id at ${path}: ${value}`);
|
|
596
|
+
allIds.add(value);
|
|
597
|
+
};
|
|
598
|
+
if (isObject(data.project)) registerId(data.project.id, 'project.id');
|
|
599
|
+
|
|
600
|
+
if (!Array.isArray(data.documents)) {
|
|
601
|
+
errors.push('documents must be an array');
|
|
602
|
+
} else {
|
|
603
|
+
for (const [index, document] of data.documents.entries()) {
|
|
604
|
+
const path = `documents[${index}]`;
|
|
605
|
+
if (!isObject(document)) {
|
|
606
|
+
errors.push(`${path} must be an object`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
validateString(document.id, `${path}.id`, errors, { allowEmpty: false });
|
|
610
|
+
validateString(document.file, `${path}.file`, errors, { allowEmpty: false });
|
|
611
|
+
validateString(document.title, `${path}.title`, errors);
|
|
612
|
+
validateString(document.summary, `${path}.summary`, errors);
|
|
613
|
+
validateString(document.corePrompt, `${path}.corePrompt`, errors);
|
|
614
|
+
if (document.sourceHash !== undefined) validateString(document.sourceHash, `${path}.sourceHash`, errors, { allowEmpty: false });
|
|
615
|
+
if (document.sourceLength !== undefined && (!Number.isInteger(document.sourceLength) || document.sourceLength < 0)) {
|
|
616
|
+
errors.push(`${path}.sourceLength must be a non-negative integer`);
|
|
617
|
+
}
|
|
618
|
+
registerId(document.id, `${path}.id`);
|
|
619
|
+
if (typeof document.id === 'string') {
|
|
620
|
+
documentIds.add(document.id);
|
|
621
|
+
targetIds.add(document.id);
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(document.sections)) {
|
|
624
|
+
errors.push(`${path}.sections must be an array`);
|
|
625
|
+
} else {
|
|
626
|
+
document.sections.forEach((section, sectionIndex) => {
|
|
627
|
+
validateNode(section, 'section', `${path}.sections[${sectionIndex}]`, errors);
|
|
628
|
+
});
|
|
629
|
+
const nodeIds = new Set();
|
|
630
|
+
collectNodeIds(document.sections, nodeIds, errors);
|
|
631
|
+
for (const nodeId of nodeIds) {
|
|
632
|
+
registerId(nodeId, `${path}.sections`);
|
|
633
|
+
targetIds.add(nodeId);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!isObject(data.libraries)) {
|
|
640
|
+
errors.push('libraries must be an object');
|
|
641
|
+
} else {
|
|
642
|
+
if (!Array.isArray(data.libraries.corpora)) errors.push('libraries.corpora must be an array');
|
|
643
|
+
else data.libraries.corpora.forEach((item, index) => {
|
|
644
|
+
validateCorpus(item, `libraries.corpora[${index}]`, errors);
|
|
645
|
+
registerId(item?.id, `libraries.corpora[${index}].id`);
|
|
646
|
+
});
|
|
647
|
+
if (!Array.isArray(data.libraries.sentencePatterns)) errors.push('libraries.sentencePatterns must be an array');
|
|
648
|
+
else data.libraries.sentencePatterns.forEach((item, index) => {
|
|
649
|
+
validateSentencePattern(item, `libraries.sentencePatterns[${index}]`, errors);
|
|
650
|
+
registerId(item?.id, `libraries.sentencePatterns[${index}].id`);
|
|
651
|
+
});
|
|
652
|
+
if (!isObject(data.libraries.vocabulary)) {
|
|
653
|
+
errors.push('libraries.vocabulary must be an object');
|
|
654
|
+
} else {
|
|
655
|
+
if (!Array.isArray(data.libraries.vocabulary.global)) errors.push('libraries.vocabulary.global must be an array');
|
|
656
|
+
else data.libraries.vocabulary.global.forEach((item, index) => {
|
|
657
|
+
validateVocabulary(item, `libraries.vocabulary.global[${index}]`, errors);
|
|
658
|
+
registerId(item?.id, `libraries.vocabulary.global[${index}].id`);
|
|
659
|
+
});
|
|
660
|
+
if (!Array.isArray(data.libraries.vocabulary.session)) errors.push('libraries.vocabulary.session must be an array');
|
|
661
|
+
else data.libraries.vocabulary.session.forEach((item, index) => {
|
|
662
|
+
validateVocabulary(item, `libraries.vocabulary.session[${index}]`, errors);
|
|
663
|
+
registerId(item?.id, `libraries.vocabulary.session[${index}].id`);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (!Array.isArray(data.annotations)) errors.push('annotations must be an array');
|
|
669
|
+
else data.annotations.forEach((item, index) => {
|
|
670
|
+
const path = `annotations[${index}]`;
|
|
671
|
+
validateAnnotation(item, path, errors);
|
|
672
|
+
registerId(item?.id, `${path}.id`);
|
|
673
|
+
if (typeof item?.documentId === 'string' && !documentIds.has(item.documentId)) errors.push(`${path}.documentId does not reference a document`);
|
|
674
|
+
if (item?.target?.id && !targetIds.has(item.target.id)) errors.push(`${path}.target.id does not reference a document node`);
|
|
675
|
+
item?.dependsOn?.forEach((annotationId, dependencyIndex) => {
|
|
676
|
+
if (!data.annotations.some((annotation) => annotation.id === annotationId)) errors.push(`${path}.dependsOn[${dependencyIndex}] does not reference an annotation`);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
if (!Array.isArray(data.reviews)) errors.push('reviews must be an array');
|
|
680
|
+
else data.reviews.forEach((item, index) => {
|
|
681
|
+
const path = `reviews[${index}]`;
|
|
682
|
+
validateReview(item, path, errors);
|
|
683
|
+
registerId(item?.id, `${path}.id`);
|
|
684
|
+
if (typeof item?.documentId === 'string' && !documentIds.has(item.documentId)) errors.push(`${path}.documentId does not reference a document`);
|
|
685
|
+
});
|
|
686
|
+
if (!Array.isArray(data.revisions)) errors.push('revisions must be an array');
|
|
687
|
+
else data.revisions.forEach((item, index) => {
|
|
688
|
+
const path = `revisions[${index}]`;
|
|
689
|
+
validateRevision(item, path, errors);
|
|
690
|
+
registerId(item?.id, `${path}.id`);
|
|
691
|
+
if (typeof item?.documentId === 'string' && !documentIds.has(item.documentId)) errors.push(`${path}.documentId does not reference a document`);
|
|
692
|
+
item?.annotationIds?.forEach((annotationId, annotationIndex) => {
|
|
693
|
+
if (!data.annotations?.some((annotation) => annotation.id === annotationId)) errors.push(`${path}.annotationIds[${annotationIndex}] does not reference an annotation`);
|
|
694
|
+
});
|
|
695
|
+
item?.changes?.forEach((change, changeIndex) => registerId(change?.id, `${path}.changes[${changeIndex}].id`));
|
|
696
|
+
});
|
|
697
|
+
if (!Array.isArray(data.agentRuns)) errors.push('agentRuns must be an array');
|
|
698
|
+
else data.agentRuns.forEach((item, index) => {
|
|
699
|
+
validateAgentRun(item, `agentRuns[${index}]`, errors);
|
|
700
|
+
registerId(item?.id, `agentRuns[${index}].id`);
|
|
701
|
+
});
|
|
702
|
+
if (!Array.isArray(data.orchestrations)) errors.push('orchestrations must be an array');
|
|
703
|
+
else data.orchestrations.forEach((item, index) => {
|
|
704
|
+
const path = `orchestrations[${index}]`;
|
|
705
|
+
validateOrchestration(item, path, errors);
|
|
706
|
+
registerId(item?.id, `${path}.id`);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return { ok: errors.length === 0, errors };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function projectPath(workspaceRoot) {
|
|
713
|
+
return join(workspaceRoot, PROJECT_DIR, PROJECT_FILE);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function repairDanglingAnnotationTargets(data) {
|
|
717
|
+
const documentIds = new Set((data.documents || []).map((document) => document.id));
|
|
718
|
+
const targetIds = new Set(documentIds);
|
|
719
|
+
const visit = (items) => {
|
|
720
|
+
for (const item of items || []) {
|
|
721
|
+
if (item?.id) targetIds.add(item.id);
|
|
722
|
+
visit(item?.children);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
for (const document of data.documents || []) visit(document.sections);
|
|
726
|
+
let repaired = false;
|
|
727
|
+
for (const annotation of data.annotations || []) {
|
|
728
|
+
if (!documentIds.has(annotation?.documentId) || !annotation?.target?.id || targetIds.has(annotation.target.id)) continue;
|
|
729
|
+
annotation.target = {
|
|
730
|
+
...annotation.target,
|
|
731
|
+
type: 'document',
|
|
732
|
+
id: annotation.documentId,
|
|
733
|
+
start: 0,
|
|
734
|
+
end: 0,
|
|
735
|
+
};
|
|
736
|
+
repaired = true;
|
|
737
|
+
}
|
|
738
|
+
return repaired;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export async function loadProject(workspaceRoot) {
|
|
742
|
+
const file = projectPath(workspaceRoot);
|
|
743
|
+
try {
|
|
744
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
745
|
+
const migration = migrateProjectData(parsed, workspaceRoot);
|
|
746
|
+
const data = migration.data;
|
|
747
|
+
const repairedDanglingTargets = repairDanglingAnnotationTargets(data);
|
|
748
|
+
const validation = validateProject(data);
|
|
749
|
+
if (!validation.ok) {
|
|
750
|
+
throw new Error(`Invalid project data: ${validation.errors.join('; ')}`);
|
|
751
|
+
}
|
|
752
|
+
if (migration.migratedFrom !== null || repairedDanglingTargets) return await saveProject(workspaceRoot, data);
|
|
753
|
+
return data;
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (error.code !== 'ENOENT') throw error;
|
|
756
|
+
const data = createDefaultProject(workspaceRoot);
|
|
757
|
+
await saveProject(workspaceRoot, data);
|
|
758
|
+
return data;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export async function saveProject(workspaceRoot, data) {
|
|
763
|
+
const validation = validateProject(data);
|
|
764
|
+
if (!validation.ok) {
|
|
765
|
+
const error = new Error('Invalid project data');
|
|
766
|
+
error.code = 'INVALID_PROJECT';
|
|
767
|
+
error.details = validation.errors;
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const directory = join(workspaceRoot, PROJECT_DIR);
|
|
772
|
+
const file = projectPath(workspaceRoot);
|
|
773
|
+
const temporary = join(directory, `project.${process.pid}.${randomUUID()}.tmp`);
|
|
774
|
+
const stored = structuredClone(data);
|
|
775
|
+
stored.project.updatedAt = now();
|
|
776
|
+
|
|
777
|
+
await mkdir(directory, { recursive: true });
|
|
778
|
+
await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
779
|
+
// On Windows the atomic rename can transiently fail with EPERM/EBUSY when another
|
|
780
|
+
// handle briefly holds the destination (e.g. a concurrent read during Agent runs).
|
|
781
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
782
|
+
try {
|
|
783
|
+
await rename(temporary, file);
|
|
784
|
+
break;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt > 10) throw error;
|
|
787
|
+
await new Promise((resolve) => setTimeout(resolve, 20 * attempt));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return stored;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export async function updateProject(workspaceRoot, mutate) {
|
|
794
|
+
const previous = updateQueues.get(workspaceRoot) || Promise.resolve();
|
|
795
|
+
const operation = previous.catch(() => {}).then(async () => {
|
|
796
|
+
const current = await loadProject(workspaceRoot);
|
|
797
|
+
const draft = structuredClone(current);
|
|
798
|
+
const result = await mutate(draft);
|
|
799
|
+
const project = await saveProject(workspaceRoot, draft);
|
|
800
|
+
return { project, result };
|
|
801
|
+
});
|
|
802
|
+
updateQueues.set(workspaceRoot, operation);
|
|
803
|
+
try {
|
|
804
|
+
return await operation;
|
|
805
|
+
} finally {
|
|
806
|
+
if (updateQueues.get(workspaceRoot) === operation) updateQueues.delete(workspaceRoot);
|
|
807
|
+
}
|
|
808
|
+
}
|