@prodero/curator 0.0.0-stage → 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/ARCHITECTURE.md +298 -0
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +9 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/SECURITY.md +7 -0
- package/bin/prodero +7 -0
- package/dist/cli.js +228 -0
- package/dist/context-instructions.js +2 -0
- package/dist/contracts.js +50 -0
- package/dist/conversation-presentation.js +262 -0
- package/dist/core/agent-instructions.js +39 -0
- package/dist/core/applicability-label.js +11 -0
- package/dist/core/applicability.js +143 -0
- package/dist/core/bootstrap.js +129 -0
- package/dist/core/consolidation.js +315 -0
- package/dist/core/constants.js +21 -0
- package/dist/core/context-allocation.js +54 -0
- package/dist/core/context-snapshot.js +12 -0
- package/dist/core/context.js +489 -0
- package/dist/core/curation-clauses.js +68 -0
- package/dist/core/curation-evidence.js +98 -0
- package/dist/core/curation-graphical.js +105 -0
- package/dist/core/curation-review-presentation.js +4 -0
- package/dist/core/curation-review-state.js +62 -0
- package/dist/core/curation-review.js +573 -0
- package/dist/core/curation-revision.js +106 -0
- package/dist/core/curation-semantic-contract.js +239 -0
- package/dist/core/curation-semantic-jobs.js +280 -0
- package/dist/core/diagnostics.js +17 -0
- package/dist/core/format.js +114 -0
- package/dist/core/ids.js +6 -0
- package/dist/core/imports.js +169 -0
- package/dist/core/index.js +93 -0
- package/dist/core/instruction-review.js +86 -0
- package/dist/core/integrity-files.js +174 -0
- package/dist/core/intelligence.js +95 -0
- package/dist/core/knowledge-analysis.js +46 -0
- package/dist/core/knowledge-authority.js +143 -0
- package/dist/core/knowledge-curation.js +316 -0
- package/dist/core/knowledge-engine.js +475 -0
- package/dist/core/knowledge-graph.js +191 -0
- package/dist/core/knowledge-lifecycle.js +212 -0
- package/dist/core/knowledge-management.js +113 -0
- package/dist/core/knowledge-migration.js +84 -0
- package/dist/core/knowledge-policy.js +38 -0
- package/dist/core/knowledge-provenance.js +90 -0
- package/dist/core/knowledge-write-policy.js +85 -0
- package/dist/core/language.js +14 -0
- package/dist/core/matching.js +6 -0
- package/dist/core/memory-index.js +239 -0
- package/dist/core/memory-intelligence.js +288 -0
- package/dist/core/memory-link-repair.js +66 -0
- package/dist/core/memory-operations.js +122 -0
- package/dist/core/memory-pressure.js +212 -0
- package/dist/core/memory-sync-curator.js +62 -0
- package/dist/core/memory-sync.js +463 -0
- package/dist/core/memory-transformation-bundle.js +163 -0
- package/dist/core/memory-transformation-proposal.js +135 -0
- package/dist/core/memory-transformation.js +108 -0
- package/dist/core/memory.js +186 -0
- package/dist/core/migrations.js +569 -0
- package/dist/core/path-scope.js +27 -0
- package/dist/core/plugin-files.js +116 -0
- package/dist/core/project-root.js +39 -0
- package/dist/core/project.js +634 -0
- package/dist/core/promotion.js +279 -0
- package/dist/core/reconciliation.js +61 -0
- package/dist/core/secrets.js +67 -0
- package/dist/core/semantic-safety.js +33 -0
- package/dist/core/session-index.js +112 -0
- package/dist/core/session-review.js +150 -0
- package/dist/core/sessions.js +289 -0
- package/dist/core/settings.js +76 -0
- package/dist/core/skill-curation.js +239 -0
- package/dist/core/skill-polish.js +41 -0
- package/dist/core/skills.js +368 -0
- package/dist/core/storage.js +408 -0
- package/dist/core/sync-transaction.js +254 -0
- package/dist/core/telemetry.js +64 -0
- package/dist/core/templates.js +53 -0
- package/dist/core/types.js +2 -0
- package/dist/core/update.js +320 -0
- package/dist/curation-review-adapter.js +92 -0
- package/dist/diagnostic-boundary.js +54 -0
- package/dist/filesystem/permissions.js +39 -0
- package/dist/hosts/adapters.js +119 -0
- package/dist/hosts/files.js +133 -0
- package/dist/hosts/manager.js +150 -0
- package/dist/hosts/public-entry.js +47 -0
- package/dist/hosts/runtime-command.js +66 -0
- package/dist/hosts/runtime.js +139 -0
- package/dist/hosts/wizard.js +114 -0
- package/dist/knowledge-table.js +38 -0
- package/dist/mcp-development.js +69 -0
- package/dist/mcp-workspace.js +141 -0
- package/dist/memory-presentation.js +172 -0
- package/dist/plugins/api.js +2 -0
- package/dist/plugins/context.js +20 -0
- package/dist/plugins/mcp.js +72 -0
- package/dist/plugins/registry.js +106 -0
- package/dist/presentation-cards.js +32 -0
- package/dist/presentation-server.js +79 -0
- package/dist/presentation-trace.js +174 -0
- package/dist/presentation.js +412 -0
- package/dist/providers/mcp-sampling.js +45 -0
- package/dist/server.js +656 -0
- package/dist/settings-definition.js +22 -0
- package/dist/skill-instructions.js +2 -0
- package/dist-ui/index.html +214 -0
- package/docs/APPLICABILITY.md +151 -0
- package/docs/COMPATIBILITY.md +49 -0
- package/docs/CONTEXT-SELECTION-V2.md +61 -0
- package/docs/CURATION-APP-REVIEW.md +156 -0
- package/docs/CURATION-CLAUSE-REFINEMENT.md +45 -0
- package/docs/IMPORTER-CURATION.md +142 -0
- package/docs/KNOWLEDGE-AUTHORITY.md +165 -0
- package/docs/KNOWLEDGE-SCHEMA-V2.md +150 -0
- package/docs/MIGRATIONS.md +99 -0
- package/docs/PRIVACY.md +28 -0
- package/docs/RECOVERY.md +56 -0
- package/docs/RELEASING.md +40 -0
- package/docs/hosts.md +134 -0
- package/docs/plugins.md +83 -0
- package/docs/ui-themes.md +43 -0
- package/instructions/AGENTS.md +33 -0
- package/package.json +88 -4
- package/plugins/migrate_ai_studio/README.md +101 -0
- package/plugins/migrate_ai_studio/index.cjs +396 -0
- package/plugins/migrate_ai_studio/instructions/audit.md +25 -0
- package/plugins/migrate_ai_studio/instructions/classify.md +41 -0
- package/plugins/migrate_ai_studio/instructions/migrate.md +34 -0
- package/plugins/migrate_ai_studio/instructions/verify.md +20 -0
- package/plugins/migrate_ai_studio/plugin.json +11 -0
- package/plugins/migrate_ai_studio/schema.cjs +20 -0
- package/plugins/migrate_ai_studio/ui/dist/index.html +77 -0
- package/prodero.release.json +5 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, tightenPrivateMode } from "../filesystem/permissions.js";
|
|
2
|
+
import { presentationConfigSchema } from "../settings-definition.js";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { PRODERO_DIRECTORY, PROJECT_DIRECTORIES, PRODERO_SCHEMA_VERSION, PRODERO_KNOWLEDGE_SCHEMA_VERSION } from "./constants.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const LOCAL_STATE_DIRECTORIES = ["local", "sessions", "checkpoints", ".cache", ".transactions", ".backups", ".migrations"];
|
|
9
|
+
export const LOCAL_STATE_IGNORE_RULES = [
|
|
10
|
+
...LOCAL_STATE_DIRECTORIES.flatMap(directory => ["sessions", "checkpoints"].includes(directory)
|
|
11
|
+
? [`${PRODERO_DIRECTORY}/${directory}/*`, `!${PRODERO_DIRECTORY}/${directory}/.gitkeep`]
|
|
12
|
+
: [`${PRODERO_DIRECTORY}/${directory}/`]),
|
|
13
|
+
// Atomic scratch files are never Versioned content, even next to a Versioned target.
|
|
14
|
+
`${PRODERO_DIRECTORY}/**/*.tmp-*`,
|
|
15
|
+
];
|
|
16
|
+
const ignoreUpdates = new Map();
|
|
17
|
+
/** Install directory-wide exclusions before creating Local/runtime state. Preserve user rules/files. */
|
|
18
|
+
export async function ensureLocalStateIgnored(root) {
|
|
19
|
+
const target = path.resolve(root, ".gitignore");
|
|
20
|
+
const pending = ignoreUpdates.get(target);
|
|
21
|
+
if (pending)
|
|
22
|
+
return pending;
|
|
23
|
+
const update = (async () => {
|
|
24
|
+
await assertSafePath(target);
|
|
25
|
+
const existing = await fs.readFile(target, "utf8").catch(error => { if (error.code === "ENOENT")
|
|
26
|
+
return ""; throw error; });
|
|
27
|
+
const lines = existing.split(/\r?\n/);
|
|
28
|
+
const missing = LOCAL_STATE_IGNORE_RULES.filter(rule => !lines.includes(rule));
|
|
29
|
+
if (missing.length)
|
|
30
|
+
await writeFileAtomic(target, `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
|
|
31
|
+
})();
|
|
32
|
+
ignoreUpdates.set(target, update);
|
|
33
|
+
try {
|
|
34
|
+
await update;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
ignoreUpdates.delete(target);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The outer private store wins for nested migration staging projects. */
|
|
41
|
+
export function privateStateRoot(target) {
|
|
42
|
+
const parts = path.resolve(target).split(path.sep);
|
|
43
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
44
|
+
if (parts[i] === PRODERO_DIRECTORY && LOCAL_STATE_DIRECTORIES.some(name => name === parts[i + 1]))
|
|
45
|
+
return parts.slice(0, i + 2).join(path.sep);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export async function protectPrivatePath(target) {
|
|
50
|
+
const root = privateStateRoot(target);
|
|
51
|
+
if (!root)
|
|
52
|
+
return;
|
|
53
|
+
await assertSafePath(target);
|
|
54
|
+
let cursor = root;
|
|
55
|
+
for (const part of ["", ...path.relative(root, target).split(path.sep).filter(Boolean)]) {
|
|
56
|
+
if (part)
|
|
57
|
+
cursor = path.join(cursor, part);
|
|
58
|
+
await tightenPrivateMode(cursor);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Create only the requested path; never recursively chmod preexisting children. */
|
|
62
|
+
export async function ensureStorageDirectory(target, forcePrivate = false) {
|
|
63
|
+
await assertSafePath(target);
|
|
64
|
+
const privateRoot = privateStateRoot(target);
|
|
65
|
+
if (privateRoot)
|
|
66
|
+
await fs.mkdir(path.dirname(privateRoot), { recursive: true });
|
|
67
|
+
await fs.mkdir(target, { recursive: true, ...(privateRoot || forcePrivate ? { mode: PRIVATE_DIRECTORY_MODE } : {}) });
|
|
68
|
+
await assertSafePath(target);
|
|
69
|
+
await protectPrivatePath(target);
|
|
70
|
+
if (forcePrivate)
|
|
71
|
+
await tightenPrivateMode(target);
|
|
72
|
+
}
|
|
73
|
+
export async function writeFileExclusive(target, content) {
|
|
74
|
+
await assertSafePath(target);
|
|
75
|
+
await protectPrivatePath(target);
|
|
76
|
+
const privateFile = Boolean(privateStateRoot(target));
|
|
77
|
+
const handle = await fs.open(target, "wx", privateFile ? PRIVATE_FILE_MODE : 0o666);
|
|
78
|
+
try {
|
|
79
|
+
if (privateFile && process.platform !== "win32")
|
|
80
|
+
await handle.chmod(PRIVATE_FILE_MODE);
|
|
81
|
+
await handle.writeFile(content);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Copies are new Curator-owned artifacts, so their entire destination tree may be secured. */
|
|
88
|
+
export async function copyStorageTree(source, target, forcePrivate = false, sharedMode) {
|
|
89
|
+
await assertSafePath(source);
|
|
90
|
+
await assertSafePath(target);
|
|
91
|
+
const stat = await fs.lstat(source);
|
|
92
|
+
const savedMode = !forcePrivate && !privateStateRoot(target) ? sharedMode?.(target, stat.mode & 0o777) : undefined;
|
|
93
|
+
if (stat.isDirectory()) {
|
|
94
|
+
await ensureStorageDirectory(target, forcePrivate);
|
|
95
|
+
if (savedMode !== undefined && process.platform !== "win32")
|
|
96
|
+
await fs.chmod(target, savedMode);
|
|
97
|
+
for (const name of await fs.readdir(source))
|
|
98
|
+
await copyStorageTree(path.join(source, name), path.join(target, name), forcePrivate, sharedMode);
|
|
99
|
+
}
|
|
100
|
+
else if (stat.isFile()) {
|
|
101
|
+
const privateFile = forcePrivate || Boolean(privateStateRoot(target));
|
|
102
|
+
// Open the destination restrictively BEFORE copying any bytes (copyFile inherits source mode).
|
|
103
|
+
const handle = await fs.open(target, "wx", privateFile ? PRIVATE_FILE_MODE : savedMode ?? 0o666);
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform !== "win32" && (privateFile || savedMode !== undefined))
|
|
106
|
+
await handle.chmod(privateFile ? PRIVATE_FILE_MODE : savedMode);
|
|
107
|
+
const input = await fs.open(source, "r");
|
|
108
|
+
try {
|
|
109
|
+
for await (const chunk of input.createReadStream({ autoClose: false }))
|
|
110
|
+
await handle.writeFile(chunk);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await input.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await handle.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
throw new Error("Unsupported migration/private copy source");
|
|
122
|
+
}
|
|
123
|
+
export async function exists(file) {
|
|
124
|
+
try {
|
|
125
|
+
await fs.access(file);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code !== "ENOENT")
|
|
130
|
+
throw error;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function projectDataPath(root, ...parts) {
|
|
135
|
+
const base = path.resolve(root, PRODERO_DIRECTORY);
|
|
136
|
+
const target = path.resolve(base, ...parts);
|
|
137
|
+
if (target !== base && !target.startsWith(`${base}${path.sep}`))
|
|
138
|
+
throw new Error("Unsafe Prodero storage path");
|
|
139
|
+
return target;
|
|
140
|
+
}
|
|
141
|
+
/** Reject links in every existing component, including dangling links. Never follow them on writes. */
|
|
142
|
+
export async function assertSafePath(target) {
|
|
143
|
+
const absolute = path.resolve(target);
|
|
144
|
+
// Resolve OS aliases such as /tmp first; the storage boundary itself must never be an alias.
|
|
145
|
+
const marker = `${path.sep}${PRODERO_DIRECTORY}`;
|
|
146
|
+
const index = absolute.indexOf(`${marker}${path.sep}`);
|
|
147
|
+
const boundary = index >= 0 ? index : absolute.endsWith(marker) ? absolute.length - marker.length : -1;
|
|
148
|
+
const parent = boundary >= 0 ? absolute.slice(0, boundary) : path.dirname(absolute);
|
|
149
|
+
let cursor = await fs.realpath(parent);
|
|
150
|
+
const parts = boundary >= 0 ? absolute.slice(boundary + 1).split(path.sep) : [path.basename(absolute)];
|
|
151
|
+
for (const [offset, part] of parts.entries()) {
|
|
152
|
+
cursor = path.join(cursor, part);
|
|
153
|
+
try {
|
|
154
|
+
const stat = await fs.lstat(cursor);
|
|
155
|
+
if (stat.isSymbolicLink())
|
|
156
|
+
throw new Error(`Symlink in Prodero storage path: ${cursor}`);
|
|
157
|
+
if (offset < parts.length - 1 && !stat.isDirectory())
|
|
158
|
+
throw new Error(`Invalid Prodero storage directory: ${cursor}`);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "ENOENT")
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Storage-level barrier also protects direct domain/plugin calls and recovery writes.
|
|
167
|
+
* Inspect only the version discriminator, never interpret unknown canonical content.
|
|
168
|
+
* Malformed config is handled by readConfig or interrupted-migration recovery.
|
|
169
|
+
*/
|
|
170
|
+
export async function assertCompatibleProject(root) {
|
|
171
|
+
const target = projectDataPath(root, "config.json");
|
|
172
|
+
await assertSafePath(target);
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = JSON.parse(await fs.readFile(target, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof SyntaxError || error.code === "ENOENT")
|
|
179
|
+
return;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const version = raw && typeof raw === "object" && "schemaVersion" in raw ? raw.schemaVersion : undefined;
|
|
183
|
+
assertSupportedSchema(version);
|
|
184
|
+
assertSupportedKnowledgeSchema(raw && typeof raw === "object" && "knowledgeSchemaVersion" in raw ? raw.knowledgeSchemaVersion : undefined);
|
|
185
|
+
}
|
|
186
|
+
export function assertSupportedSchema(version) {
|
|
187
|
+
if (typeof version === "number" && version > PRODERO_SCHEMA_VERSION) {
|
|
188
|
+
throw new Error(`PRODERO_SCHEMA_NEWER: Project schema v${version} is newer than Prodero runtime schema v${PRODERO_SCHEMA_VERSION}. Update Prodero before reading or changing this project.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export function assertSupportedKnowledgeSchema(version) {
|
|
192
|
+
if (version !== undefined && (!Number.isInteger(version) || Number(version) < 1))
|
|
193
|
+
throw new Error("PRODERO_INVALID_CONFIG: Invalid knowledge schema version.");
|
|
194
|
+
if (typeof version === "number" && version > PRODERO_KNOWLEDGE_SCHEMA_VERSION)
|
|
195
|
+
throw new Error(`PRODERO_KNOWLEDGE_SCHEMA_NEWER: Knowledge format v${version} requires a newer Curator. Update Prodero before reading or changing knowledge.`);
|
|
196
|
+
}
|
|
197
|
+
export async function checkedDataPath(root, ...parts) {
|
|
198
|
+
await assertCompatibleProject(root);
|
|
199
|
+
const target = projectDataPath(root, ...parts);
|
|
200
|
+
await assertSafePath(target);
|
|
201
|
+
await protectPrivatePath(target);
|
|
202
|
+
return target;
|
|
203
|
+
}
|
|
204
|
+
export async function ensureDataDirectory(root, relative) {
|
|
205
|
+
const target = await checkedDataPath(root, relative);
|
|
206
|
+
const directory = path.relative(projectDataPath(root), target).split(path.sep)[0];
|
|
207
|
+
if (LOCAL_STATE_DIRECTORIES.some(local => local === directory))
|
|
208
|
+
await ensureLocalStateIgnored(root);
|
|
209
|
+
await ensureStorageDirectory(target);
|
|
210
|
+
return target;
|
|
211
|
+
}
|
|
212
|
+
export function canonicalId(value, filename) {
|
|
213
|
+
if (typeof value !== "string" || !/^[a-zA-Z0-9_-]+$/.test(value))
|
|
214
|
+
throw new Error(`Invalid canonical ID: ${filename}`);
|
|
215
|
+
}
|
|
216
|
+
const positive = z.number().int().positive();
|
|
217
|
+
const configSchema = z.object({
|
|
218
|
+
schemaVersion: positive, knowledgeSchemaVersion: positive.optional(), initializedWith: z.string().min(1), lastUpdatedWith: z.string().min(1),
|
|
219
|
+
projectId: z.string().min(1), createdAt: z.string().refine(v => Number.isFinite(Date.parse(v))),
|
|
220
|
+
context: z.object({ max_tokens: positive.optional(), max_items: positive.optional(), required_max_tokens: z.number().int().nonnegative().optional(), budgetTokens: positive.optional(), requiredBudgetTokens: z.number().int().nonnegative().optional() }).passthrough().optional(),
|
|
221
|
+
presentation: presentationConfigSchema.optional(),
|
|
222
|
+
knowledgeEngine: z.object({ mode: z.enum(["deterministic", "assisted"]) }).passthrough().optional(),
|
|
223
|
+
}).passthrough();
|
|
224
|
+
export function validateConfig(value) {
|
|
225
|
+
const result = configSchema.safeParse(value);
|
|
226
|
+
if (!result.success)
|
|
227
|
+
throw new Error(`Invalid Prodero config: .prodero/config.json (${result.error.issues.map(i => i.path.join(".")).join(", ")}).`);
|
|
228
|
+
}
|
|
229
|
+
/** Ensure only the disposable working-tree-local derived-state root. */
|
|
230
|
+
export async function ensureDerivedStateRoot(root) {
|
|
231
|
+
return ensureDataDirectory(root, ".cache");
|
|
232
|
+
}
|
|
233
|
+
export async function ensureProjectDirectories(root) {
|
|
234
|
+
await Promise.all([
|
|
235
|
+
ensureDerivedStateRoot(root),
|
|
236
|
+
...PROJECT_DIRECTORIES.filter((directory) => directory !== ".cache").map((directory) => ensureDataDirectory(root, directory)),
|
|
237
|
+
]);
|
|
238
|
+
await Promise.all(PROJECT_DIRECTORIES.filter((directory) => directory !== ".cache")
|
|
239
|
+
.map(async (directory) => { const target = await checkedDataPath(root, directory, ".gitkeep"); if (!(await exists(target)))
|
|
240
|
+
await writeFileExclusive(target, "").catch(error => { if (error.code !== "EEXIST")
|
|
241
|
+
throw error; }); }));
|
|
242
|
+
}
|
|
243
|
+
/** Missing ignored directories are normal after clone. Do not touch tracked placeholders. */
|
|
244
|
+
export async function ensureRuntimeDirectories(root) {
|
|
245
|
+
for (const directory of ["local/memory", "local/skills", "sessions", "checkpoints", ".cache"])
|
|
246
|
+
await ensureDataDirectory(root, directory);
|
|
247
|
+
// Protect dormant legacy stores at their boundary without walking their contents.
|
|
248
|
+
for (const directory of LOCAL_STATE_DIRECTORIES)
|
|
249
|
+
await protectPrivatePath(projectDataPath(root, directory));
|
|
250
|
+
}
|
|
251
|
+
export async function countContentFiles(directory) {
|
|
252
|
+
if (!(await exists(directory)))
|
|
253
|
+
return 0;
|
|
254
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
255
|
+
return entries.filter((entry) => entry.isFile() && !entry.name.startsWith(".")).length;
|
|
256
|
+
}
|
|
257
|
+
export async function readConfig(root) {
|
|
258
|
+
const configPath = await checkedDataPath(root, "config.json");
|
|
259
|
+
if (!(await exists(configPath)))
|
|
260
|
+
return null;
|
|
261
|
+
let config;
|
|
262
|
+
try {
|
|
263
|
+
config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error instanceof SyntaxError)
|
|
267
|
+
throw new Error("PRODERO_INVALID_CONFIG: Invalid Prodero config: .prodero/config.json is not valid JSON. Restore valid configuration or use init to recover an interrupted migration.");
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
validateConfig(config);
|
|
271
|
+
return config;
|
|
272
|
+
}
|
|
273
|
+
export async function writeConfig(root, config) {
|
|
274
|
+
validateConfig(config);
|
|
275
|
+
assertSupportedSchema(config.schemaVersion);
|
|
276
|
+
assertSupportedKnowledgeSchema(config.knowledgeSchemaVersion);
|
|
277
|
+
await writeFileAtomic(await checkedDataPath(root, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
|
278
|
+
}
|
|
279
|
+
export async function writeFileAtomic(target, content) {
|
|
280
|
+
await assertSafePath(target);
|
|
281
|
+
await protectPrivatePath(target);
|
|
282
|
+
const temporary = `${target}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
283
|
+
try {
|
|
284
|
+
await writeFileExclusive(temporary, content);
|
|
285
|
+
await fs.rename(temporary, target);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
await fs.rm(temporary, { force: true }).catch(() => { });
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const checkpointSchema = z.object({
|
|
293
|
+
id: z.string().regex(/^[a-zA-Z0-9_-]+$/), createdAt: z.string().refine(v => Number.isFinite(Date.parse(v))),
|
|
294
|
+
sessions: z.array(z.object({ id: z.string().nullable().optional(), path: z.string().regex(/^[a-zA-Z0-9_-]+\.md$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) })),
|
|
295
|
+
previousCheckpoint: z.string().nullable().optional(),
|
|
296
|
+
consolidation: z.enum(["not-performed", "deterministic-v1", "assisted-v1"]).optional(),
|
|
297
|
+
summary: z.object({ sessions: z.number().nonnegative(), candidates: z.number().nonnegative(), created: z.number().nonnegative(), consolidated: z.number().nonnegative(), duplicates: z.number().nonnegative(), superseded: z.number().nonnegative(), conflicts: z.number().nonnegative() }).optional(),
|
|
298
|
+
lifecycle: z.object({ operationId: z.string(), planId: z.string().regex(/^[a-f0-9]{64}$/), proposalHash: z.string().regex(/^[a-f0-9]{64}$/), transformationPreviewId: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict().optional(),
|
|
299
|
+
promotionRevisions: z.array(z.object({ path: z.string(), hash: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).optional(),
|
|
300
|
+
observationHashes: z.array(z.string()).optional(),
|
|
301
|
+
createdMemory: z.array(z.string()).optional(), updatedMemory: z.array(z.string()).optional(), supersededMemory: z.array(z.string()).optional(),
|
|
302
|
+
duplicateMemory: z.array(z.string()).optional(), createdSkills: z.array(z.string()).optional(), conflicts: z.array(z.string()).optional(),
|
|
303
|
+
}).passthrough();
|
|
304
|
+
export function parseCheckpointData(content, filename) {
|
|
305
|
+
let raw;
|
|
306
|
+
try {
|
|
307
|
+
raw = JSON.parse(content);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
throw new Error(`Invalid Prodero checkpoint: ${filename} is not valid JSON.`);
|
|
311
|
+
}
|
|
312
|
+
const result = checkpointSchema.safeParse(raw);
|
|
313
|
+
if (!result.success || (filename.startsWith("chk_") && result.data.id !== path.basename(filename, ".json")))
|
|
314
|
+
throw new Error(`Invalid Prodero checkpoint: ${filename} has malformed metadata.`);
|
|
315
|
+
return result.data;
|
|
316
|
+
}
|
|
317
|
+
export async function listCheckpoints(root) {
|
|
318
|
+
const directory = await checkedDataPath(root, "checkpoints");
|
|
319
|
+
if (!(await exists(directory)))
|
|
320
|
+
return [];
|
|
321
|
+
const names = (await fs.readdir(directory)).filter((name) => name.endsWith(".json")).sort();
|
|
322
|
+
const checkpoints = [];
|
|
323
|
+
for (const name of names) {
|
|
324
|
+
const raw = parseCheckpointData(await fs.readFile(await checkedDataPath(root, "checkpoints", name), "utf8"), name);
|
|
325
|
+
checkpoints.push({
|
|
326
|
+
id: raw.id,
|
|
327
|
+
createdAt: raw.createdAt,
|
|
328
|
+
previousCheckpoint: raw.previousCheckpoint ?? null,
|
|
329
|
+
sessions: raw.sessions.map((session) => ({ id: session.id ?? null, path: session.path, sha256: session.sha256 })),
|
|
330
|
+
consolidation: raw.consolidation ?? "not-performed",
|
|
331
|
+
createdMemory: raw.createdMemory ?? [],
|
|
332
|
+
createdSkills: raw.createdSkills ?? [],
|
|
333
|
+
updatedMemory: raw.updatedMemory ?? [],
|
|
334
|
+
supersededMemory: raw.supersededMemory ?? [],
|
|
335
|
+
duplicateMemory: raw.duplicateMemory ?? [],
|
|
336
|
+
conflicts: raw.conflicts ?? [],
|
|
337
|
+
summary: raw.summary ?? { sessions: raw.sessions.length, candidates: 0, created: 0, consolidated: 0, duplicates: 0, superseded: 0, conflicts: 0 },
|
|
338
|
+
note: raw.note,
|
|
339
|
+
importReceipt: raw.importReceipt,
|
|
340
|
+
curation: raw.curation,
|
|
341
|
+
curationDefer: raw.curationDefer,
|
|
342
|
+
lifecycle: raw.lifecycle,
|
|
343
|
+
promotionRevisions: raw.promotionRevisions,
|
|
344
|
+
intelligence: raw.intelligence, reasoning: raw.reasoning, instructionCandidates: raw.instructionCandidates, observationHashes: raw.observationHashes, knowledgeBefore: raw.knowledgeBefore,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return checkpoints;
|
|
348
|
+
}
|
|
349
|
+
export function sha256(content) {
|
|
350
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
351
|
+
}
|
|
352
|
+
/** Block ordinary readers while a recoverable lifecycle transaction has partial files. */
|
|
353
|
+
/** Optimistic read fence: a lifecycle commit during a multi-file read must be retried.
|
|
354
|
+
* The journal blocks partial writes; the immutable checkpoint set detects a commit
|
|
355
|
+
* that started AND finished between the two journal checks. No persistent state.
|
|
356
|
+
*/
|
|
357
|
+
export async function knowledgeReadFence(root) {
|
|
358
|
+
const generation = async () => {
|
|
359
|
+
const directory = await checkedDataPath(root, 'checkpoints');
|
|
360
|
+
const checkpoints = (await fs.readdir(directory).catch(error => {
|
|
361
|
+
if (error.code === 'ENOENT')
|
|
362
|
+
return [];
|
|
363
|
+
throw error;
|
|
364
|
+
})).filter(name => /^chk_.*\.json$/.test(name)).sort();
|
|
365
|
+
// Migration does not necessarily create checkpoints. History revisions fence
|
|
366
|
+
// reads spanning an entire commit or rollback, including journal removal.
|
|
367
|
+
const history = await checkedDataPath(root, ".migrations");
|
|
368
|
+
const names = await fs.readdir(history).catch(error => {
|
|
369
|
+
if (error.code === "ENOENT")
|
|
370
|
+
return [];
|
|
371
|
+
throw error;
|
|
372
|
+
});
|
|
373
|
+
const revisions = await Promise.all(names.filter(name => name.endsWith(".json")).sort().map(async (name) => {
|
|
374
|
+
const entry = await checkedDataPath(root, ".migrations", name);
|
|
375
|
+
const stat = await fs.stat(entry);
|
|
376
|
+
return [name, stat.mtimeMs, stat.ctimeMs, stat.size];
|
|
377
|
+
}));
|
|
378
|
+
return JSON.stringify({ checkpoints, migrations: revisions });
|
|
379
|
+
};
|
|
380
|
+
const before = await generation();
|
|
381
|
+
await assertLifecycleReadable(root);
|
|
382
|
+
return async () => {
|
|
383
|
+
await assertLifecycleReadable(root);
|
|
384
|
+
if (await generation() !== before)
|
|
385
|
+
throw new Error('Knowledge transaction committed during read; retry against a stable graph');
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
export async function assertLifecycleReadable(root) {
|
|
389
|
+
const migration = await checkedDataPath(root, ".transactions/migration.json");
|
|
390
|
+
if (await exists(migration)) {
|
|
391
|
+
const { ownsSyncLock } = await import("./sync-transaction.js");
|
|
392
|
+
if (!ownsSyncLock(root))
|
|
393
|
+
throw new Error("Project update is in progress or requires recovery; open Prodero before reading knowledge.");
|
|
394
|
+
}
|
|
395
|
+
const journal = await checkedDataPath(root, ".transactions/pending.json");
|
|
396
|
+
const text = await fs.readFile(journal, "utf8").catch(error => { if (error.code === "ENOENT")
|
|
397
|
+
return null; throw error; });
|
|
398
|
+
let version;
|
|
399
|
+
try {
|
|
400
|
+
version = text ? JSON.parse(text).version : undefined;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
throw new Error("PRODERO_INVALID_JOURNAL: invalid pending journal JSON; preserve it for recovery.");
|
|
404
|
+
}
|
|
405
|
+
if (version === 2)
|
|
406
|
+
throw new Error("Knowledge lifecycle transaction is pending recovery; run prepare or Sync before reading knowledge.");
|
|
407
|
+
}
|
|
408
|
+
//# sourceMappingURL=storage.js.map
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { PRIVATE_DIRECTORY_MODE } from "../filesystem/permissions.js";
|
|
2
|
+
import { writeFileExclusive } from "./storage.js";
|
|
3
|
+
import { protectKnowledgeWrites } from './knowledge-write-policy.js';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { assertNoSecrets } from "./secrets.js";
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { parseKnowledge } from './memory.js';
|
|
10
|
+
import { parseSkill } from './skills.js';
|
|
11
|
+
import { readConfig, checkedDataPath, exists, parseCheckpointData, ensureRuntimeDirectories, ensureDataDirectory, sha256, writeFileAtomic } from './storage.js';
|
|
12
|
+
const writeSchema = z.object({ path: z.string().regex(/^(memory|local\/memory|local\/skills|skills|(local\/)?archive\/\d{4}_(0[1-9]|1[0-2]))\/[a-zA-Z0-9_-]+\.md$|^checkpoints\/chk_[a-zA-Z0-9_-]+\.json$/), before: z.string().nullable(), after: z.string() }).strict();
|
|
13
|
+
const moveSchema = z.object({ from: z.string().regex(/^(local\/)?memory\/[a-zA-Z0-9_-]+\.md$/), to: z.string().regex(/^(local\/)?archive\/\d{4}_(0[1-9]|1[0-2])\/[a-zA-Z0-9_-]+\.md$/), before: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
14
|
+
const journalSchema = z.discriminatedUnion('version', [
|
|
15
|
+
z.object({ version: z.literal(1), writes: z.array(writeSchema).min(1) }).strict(),
|
|
16
|
+
z.object({ version: z.literal(2), writes: z.array(writeSchema).min(1), moves: z.array(moveSchema) }).strict(),
|
|
17
|
+
]);
|
|
18
|
+
export async function safeProjectPath(root, relative) {
|
|
19
|
+
if (!/^(memory|local\/memory|local\/skills|skills|sessions|checkpoints|(local\/)?archive\/\d{4}_(0[1-9]|1[0-2]))\/[a-zA-Z0-9_-]+\.(md|json)$/.test(relative))
|
|
20
|
+
throw new Error('Unsafe canonical path');
|
|
21
|
+
return checkedDataPath(root, relative);
|
|
22
|
+
}
|
|
23
|
+
async function runtimeDirectory(root) {
|
|
24
|
+
await ensureRuntimeDirectories(root);
|
|
25
|
+
for (const directory of ['memory', 'skills'])
|
|
26
|
+
await checkedDataPath(root, directory);
|
|
27
|
+
const dir = await ensureDataDirectory(root, '.transactions');
|
|
28
|
+
await writeFileExclusive(await checkedDataPath(root, '.transactions/.gitignore'), '*\n').catch((error) => { if (error.code !== 'EEXIST')
|
|
29
|
+
throw error; });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
export async function fileHash(target) {
|
|
33
|
+
try {
|
|
34
|
+
return sha256(await fs.readFile(target, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return null;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// An unforgeable async-context capability for Core composition, never a caller boolean.
|
|
43
|
+
const writerContext = new AsyncLocalStorage();
|
|
44
|
+
export function ownsSyncLock(root) {
|
|
45
|
+
const owner = writerContext.getStore();
|
|
46
|
+
return Boolean(owner?.active && owner.root === path.resolve(root));
|
|
47
|
+
}
|
|
48
|
+
/** Per-worktree writer exclusion shared by migration, Sync and session mutations. */
|
|
49
|
+
export async function withSyncLock(root, operation, options = {}) {
|
|
50
|
+
await runtimeDirectory(root);
|
|
51
|
+
const lock = await checkedDataPath(root, '.transactions/lock');
|
|
52
|
+
const deadline = Date.now() + (options.waitMs ?? 0);
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.mkdir(lock, { mode: PRIVATE_DIRECTORY_MODE });
|
|
56
|
+
try {
|
|
57
|
+
await writeFileExclusive(await checkedDataPath(root, '.transactions/lock/owner'), String(process.pid));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== 'EEXIST')
|
|
67
|
+
throw error;
|
|
68
|
+
await checkedDataPath(root, '.transactions/lock/owner');
|
|
69
|
+
// Only one stale-owner reaper may inspect/remove a lock. Re-read under that guard
|
|
70
|
+
// so a second reaper cannot delete a newly acquired writer's lock.
|
|
71
|
+
const reaper = await checkedDataPath(root, '.transactions/lock-recovery');
|
|
72
|
+
let reaping = false, recovered = false;
|
|
73
|
+
try {
|
|
74
|
+
try {
|
|
75
|
+
await fs.mkdir(reaper, { mode: PRIVATE_DIRECTORY_MODE });
|
|
76
|
+
reaping = true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== 'EEXIST')
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (reaping) {
|
|
83
|
+
const owner = await fs.readFile(await checkedDataPath(root, '.transactions/lock/owner'), 'utf8').catch(error => { if (error.code === 'ENOENT')
|
|
84
|
+
return ''; throw error; });
|
|
85
|
+
const pid = /^\d+$/.test(owner) ? Number(owner) : 0;
|
|
86
|
+
if (pid > 0 && Number.isSafeInteger(pid)) {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
}
|
|
90
|
+
catch (probe) {
|
|
91
|
+
if (probe.code !== 'ESRCH')
|
|
92
|
+
throw probe;
|
|
93
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
94
|
+
recovered = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (reaping)
|
|
101
|
+
await fs.rm(reaper, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
if (recovered)
|
|
104
|
+
continue;
|
|
105
|
+
if (Date.now() >= deadline)
|
|
106
|
+
throw new Error('Another Sync or storage writer is already running, or its lock has no live owner; inspect .prodero/.transactions/lock before retrying');
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
if (!options.migrationRecovery && await exists(await checkedDataPath(root, '.transactions/migration.json')))
|
|
112
|
+
throw new Error('Interrupted migration requires recovery; run Prodero init or prepare before recording new data. Preserve .prodero/.backups and .prodero/.transactions.');
|
|
113
|
+
const pending = await fs.readFile(await checkedDataPath(root, '.transactions/pending.json'), 'utf8').catch(error => { if (error.code === 'ENOENT')
|
|
114
|
+
return null; throw error; });
|
|
115
|
+
let pendingVersion;
|
|
116
|
+
try {
|
|
117
|
+
pendingVersion = pending ? JSON.parse(pending).version : undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new Error("PRODERO_INVALID_JOURNAL: invalid pending journal JSON; preserve it for recovery.");
|
|
121
|
+
}
|
|
122
|
+
if (pendingVersion === 2) {
|
|
123
|
+
if (options.recoverLifecycle === false)
|
|
124
|
+
throw new Error('Lifecycle transaction pending; explicit recovery required before preview');
|
|
125
|
+
await recoverSyncTransaction(root);
|
|
126
|
+
}
|
|
127
|
+
const owner = { root: path.resolve(root), active: true };
|
|
128
|
+
try {
|
|
129
|
+
return await writerContext.run(owner, operation);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
owner.active = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function validateWriteContent(root, writes) {
|
|
140
|
+
assertNoSecrets(writes.map(write => write.after));
|
|
141
|
+
const schema = (await readConfig(root))?.knowledgeSchemaVersion;
|
|
142
|
+
for (const write of writes) {
|
|
143
|
+
const filename = path.basename(write.path);
|
|
144
|
+
if (write.path.startsWith('checkpoints/'))
|
|
145
|
+
parseCheckpointData(write.after, filename);
|
|
146
|
+
else if ((write.path.startsWith('skills/') || write.path.startsWith('local/skills/')))
|
|
147
|
+
parseSkill(write.after, filename);
|
|
148
|
+
else
|
|
149
|
+
parseKnowledge(write.after, filename, schema);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function validateMoves(writes, moves) {
|
|
153
|
+
const sources = new Set();
|
|
154
|
+
for (const move of moves) {
|
|
155
|
+
if (sources.has(move.from) || writes.some(w => w.path === move.from))
|
|
156
|
+
throw new Error('Invalid lifecycle move source');
|
|
157
|
+
sources.add(move.from);
|
|
158
|
+
const destination = writes.find(w => w.path === move.to);
|
|
159
|
+
if (!destination || destination.before !== null || path.basename(move.from) !== path.basename(move.to) || move.from.startsWith('local/') !== move.to.startsWith('local/'))
|
|
160
|
+
throw new Error('Invalid lifecycle archive move');
|
|
161
|
+
const archived = parseKnowledge(destination.after, path.basename(move.to));
|
|
162
|
+
if (['active', 'disputed'].includes(archived.status))
|
|
163
|
+
throw new Error('Only historical knowledge can be archived');
|
|
164
|
+
if (!archived.archivedAt || !move.to.includes(`archive/${new Date(archived.archivedAt).toISOString().slice(0, 7).replace('-', '_')}/`))
|
|
165
|
+
throw new Error('Archive move requires matching archived_at');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function applyWrites(root, writes, moves = []) {
|
|
169
|
+
// Validate the entire journal and every before-image before the first write.
|
|
170
|
+
const targets = await Promise.all(writes.map((write) => safeProjectPath(root, write.path)));
|
|
171
|
+
await validateWriteContent(root, writes);
|
|
172
|
+
validateMoves(writes, moves);
|
|
173
|
+
for (const move of moves) {
|
|
174
|
+
const actual = await fileHash(await safeProjectPath(root, move.from));
|
|
175
|
+
const destination = writes.find(w => w.path === move.to);
|
|
176
|
+
if (actual !== move.before && (actual !== null || await fileHash(await safeProjectPath(root, move.to)) !== sha256(destination.after)))
|
|
177
|
+
throw new Error('Lifecycle recovery found a changed or missing source; journal preserved');
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < writes.length; i++) {
|
|
180
|
+
const actual = await fileHash(targets[i]);
|
|
181
|
+
if (actual !== writes[i].before && actual !== sha256(writes[i].after))
|
|
182
|
+
throw new Error(`Sync recovery found concurrently changed ${writes[i].path}; preserved journal for review`);
|
|
183
|
+
}
|
|
184
|
+
for (let i = 0; i < writes.length; i++) {
|
|
185
|
+
if (i === writes.length - 1)
|
|
186
|
+
for (const move of moves)
|
|
187
|
+
await fs.rm(await safeProjectPath(root, move.from), { force: true });
|
|
188
|
+
if (await fileHash(targets[i]) !== sha256(writes[i].after)) {
|
|
189
|
+
if (/^(local\/)?archive\//.test(writes[i].path))
|
|
190
|
+
await ensureDataDirectory(root, path.dirname(writes[i].path));
|
|
191
|
+
await writeFileAtomic(targets[i], writes[i].after);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function recoverSyncTransaction(root) {
|
|
196
|
+
await runtimeDirectory(root);
|
|
197
|
+
const target = await checkedDataPath(root, '.transactions/pending.json');
|
|
198
|
+
const content = await fs.readFile(target, 'utf8').catch((error) => { if (error.code === 'ENOENT')
|
|
199
|
+
return null; throw error; });
|
|
200
|
+
if (!content)
|
|
201
|
+
return false;
|
|
202
|
+
let journal;
|
|
203
|
+
try {
|
|
204
|
+
journal = journalSchema.parse(JSON.parse(content));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw new Error("PRODERO_INVALID_JOURNAL: .prodero/.transactions/pending.json has invalid JSON or metadata. Preserve the journal and inspect or restore it before retrying Sync.");
|
|
208
|
+
}
|
|
209
|
+
validateWriteOrder(journal.writes);
|
|
210
|
+
await applyWrites(root, journal.writes, journal.version === 2 ? journal.moves : []);
|
|
211
|
+
await fs.rm(target);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
function validateWriteOrder(writes) {
|
|
215
|
+
if (new Set(writes.map((w) => w.path)).size !== writes.length || !writes.at(-1)?.path.startsWith('checkpoints/') || writes.slice(0, -1).some((w) => w.path.startsWith('checkpoints/')))
|
|
216
|
+
throw new Error('Invalid Sync transaction order');
|
|
217
|
+
if (writes.at(-1).before !== null)
|
|
218
|
+
throw new Error('Checkpoint must be immutable');
|
|
219
|
+
}
|
|
220
|
+
/** Durable journal is NOT a disposable cache. Checkpoint is committed last. */
|
|
221
|
+
export async function commitSyncTransaction(root, writes, lifecycle) {
|
|
222
|
+
assertNoSecrets(writes.map(w => w.after));
|
|
223
|
+
// Validate paths and preimages before interpreting any untrusted content.
|
|
224
|
+
journalSchema.parse(lifecycle?.moves ? { version: 2, writes, moves: lifecycle.moves } : { version: 1, writes });
|
|
225
|
+
validateWriteOrder(writes);
|
|
226
|
+
for (const write of writes) {
|
|
227
|
+
if (await fileHash(await safeProjectPath(root, write.path)) !== write.before)
|
|
228
|
+
throw new Error(`Knowledge changed while reasoning: ${write.path}; retry Sync`);
|
|
229
|
+
}
|
|
230
|
+
const guarded = await protectKnowledgeWrites(root, writes, lifecycle?.policyMode === 'exact', lifecycle?.moves);
|
|
231
|
+
writes = guarded.writes;
|
|
232
|
+
const journal = journalSchema.parse(lifecycle?.moves ? { version: 2, writes, moves: lifecycle.moves } : { version: 1, writes });
|
|
233
|
+
if (lifecycle?.moves)
|
|
234
|
+
validateMoves(writes, lifecycle.moves);
|
|
235
|
+
validateWriteOrder(journal.writes);
|
|
236
|
+
for (const write of writes) {
|
|
237
|
+
const target = await safeProjectPath(root, write.path);
|
|
238
|
+
if (await fileHash(target) !== write.before)
|
|
239
|
+
throw new Error(`Knowledge changed while reasoning: ${write.path}; retry Sync`);
|
|
240
|
+
}
|
|
241
|
+
for (const move of lifecycle?.moves ?? [])
|
|
242
|
+
if (await fileHash(await safeProjectPath(root, move.from)) !== move.before)
|
|
243
|
+
throw new Error('Knowledge source changed before archive; retry lifecycle preview');
|
|
244
|
+
await runtimeDirectory(root);
|
|
245
|
+
const target = await checkedDataPath(root, '.transactions/pending.json');
|
|
246
|
+
if (await fileHash(target))
|
|
247
|
+
throw new Error('Unrecovered Sync journal');
|
|
248
|
+
await validateWriteContent(root, writes);
|
|
249
|
+
await writeFileAtomic(target, JSON.stringify(journal));
|
|
250
|
+
await applyWrites(root, journal.writes, journal.version === 2 ? journal.moves : []);
|
|
251
|
+
await fs.rm(target);
|
|
252
|
+
return guarded.policy;
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=sync-transaction.js.map
|