@openagentpack/sdk 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/chunk-56BB46YT.js +1191 -0
- package/dist/chunk-HIK5DY4I.js +2429 -0
- package/dist/chunk-ZBJDMUJT.js +6989 -0
- package/dist/directory-nY2Slsfi.d.ts +98 -0
- package/dist/{session-event-B63Rbjim.d.ts → dto-BT1Q1Ii6.d.ts} +1 -50
- package/dist/errors-BcVE1wZB.d.ts +5 -0
- package/dist/index.d.ts +7 -1128
- package/dist/index.js +249 -9534
- package/dist/project-versions.d.ts +105 -0
- package/dist/project-versions.js +35 -0
- package/dist/project-workspace.d.ts +150 -0
- package/dist/project-workspace.js +1774 -0
- package/dist/resource-runtime-DZDY45_Q.d.ts +1126 -0
- package/dist/session-event-DB_YlDiK.d.ts +52 -0
- package/dist/session-events.d.ts +2 -1
- package/package.json +13 -1
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UserError,
|
|
3
|
+
inspectProjectSource
|
|
4
|
+
} from "./chunk-ZBJDMUJT.js";
|
|
5
|
+
|
|
6
|
+
// src/internal/project-versions/directory.ts
|
|
7
|
+
import { createHash, randomUUID } from "crypto";
|
|
8
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
9
|
+
import { dirname, resolve } from "path";
|
|
10
|
+
var DIRECTORY_STORE_SCHEMA = 1;
|
|
11
|
+
var VERSION_ID = /^[a-f0-9]{64}$/;
|
|
12
|
+
var SENSITIVE_KEY = /(access[_-]?key|api[_-]?key|authorization|credential|headers?|password|secret|signature|token)/i;
|
|
13
|
+
var ENV_REFERENCE = /^\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}$/;
|
|
14
|
+
var DirectoryProjectMutationConflictError = class extends UserError {
|
|
15
|
+
status = 409;
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "DirectoryProjectMutationConflictError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function createDirectoryProjectVersionService(input) {
|
|
22
|
+
const context = directoryContext(input.projectRoot);
|
|
23
|
+
return {
|
|
24
|
+
status: async () => status(context, input.adapter),
|
|
25
|
+
enable: async (message = "Initialize project") => {
|
|
26
|
+
const lease = await acquireLease(context, "version_enable");
|
|
27
|
+
try {
|
|
28
|
+
const snapshot = await input.adapter.readSnapshot();
|
|
29
|
+
await assertSnapshotSafe(snapshot, context.projectRoot);
|
|
30
|
+
const store = await readStore(context, false) ?? emptyStore();
|
|
31
|
+
store.enabled = true;
|
|
32
|
+
const head = store.head_version ? await readEntry(context, store.head_version) : null;
|
|
33
|
+
const treeHash = snapshotTreeHash(snapshot);
|
|
34
|
+
const version = head?.tree_hash === treeHash ? null : await appendVersion(context, store, snapshot, message);
|
|
35
|
+
if (!version) await writeJsonAtomic(context.storePath, store);
|
|
36
|
+
return { version, versioning: await publicStatus(context, store, snapshot) };
|
|
37
|
+
} finally {
|
|
38
|
+
await releaseLease(context, lease.token);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
disable: async () => {
|
|
42
|
+
const lease = await acquireLease(context, "version_disable");
|
|
43
|
+
try {
|
|
44
|
+
const store = await requireStore(context);
|
|
45
|
+
store.enabled = false;
|
|
46
|
+
await writeJsonAtomic(context.storePath, store);
|
|
47
|
+
return publicStatus(context, store, await input.adapter.readSnapshot());
|
|
48
|
+
} finally {
|
|
49
|
+
await releaseLease(context, lease.token);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
listVersions: async (options = {}) => listVersions(context, options),
|
|
53
|
+
previewVersion: async (versionId) => previewVersion(context, input.adapter, versionId),
|
|
54
|
+
restoreVersion: async (versionId, base) => restoreVersion(context, input.adapter, versionId, base),
|
|
55
|
+
prepareVersion: async (provided) => prepareVersion(context, input.adapter, provided),
|
|
56
|
+
commitPrepared: async (prepared, message = "Publish project") => commitPrepared(context, prepared, message),
|
|
57
|
+
releasePrepared: async (prepared) => {
|
|
58
|
+
if (prepared) await releaseLease(context, prepared.leaseToken);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function status(context, adapter) {
|
|
63
|
+
const [store, snapshot, blocker] = await Promise.all([
|
|
64
|
+
readStore(context, false),
|
|
65
|
+
adapter.readSnapshot(),
|
|
66
|
+
mutationBlocker(context)
|
|
67
|
+
]);
|
|
68
|
+
if (!store) {
|
|
69
|
+
return {
|
|
70
|
+
initialized: false,
|
|
71
|
+
enabled: false,
|
|
72
|
+
store_root: context.storeRoot,
|
|
73
|
+
head_version: null,
|
|
74
|
+
source_status: "unversioned",
|
|
75
|
+
project_revision: snapshot.project_revision,
|
|
76
|
+
write_blockers: blocker ? [blocker] : [],
|
|
77
|
+
restore_blockers: blocker ? [blocker] : []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return publicStatus(context, store, snapshot, blocker ? [blocker] : []);
|
|
81
|
+
}
|
|
82
|
+
async function publicStatus(context, store, snapshot, blockers = []) {
|
|
83
|
+
const head = store.head_version ? await readEntry(context, store.head_version) : null;
|
|
84
|
+
return {
|
|
85
|
+
initialized: true,
|
|
86
|
+
enabled: store.enabled,
|
|
87
|
+
store_root: context.storeRoot,
|
|
88
|
+
head_version: store.head_version,
|
|
89
|
+
source_status: !head ? "unversioned" : head.tree_hash === snapshotTreeHash(snapshot) ? "clean" : "modified",
|
|
90
|
+
project_revision: snapshot.project_revision,
|
|
91
|
+
write_blockers: blockers,
|
|
92
|
+
restore_blockers: blockers
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function listVersions(context, input) {
|
|
96
|
+
const store = await requireStore(context);
|
|
97
|
+
const cursor = input.cursor ? decodeCursor(input.cursor) : { head: store.head_version, offset: 0 };
|
|
98
|
+
if (cursor.head !== store.head_version) throw new UserError("Version history changed. Restart pagination.");
|
|
99
|
+
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
|
|
100
|
+
const chain = await readChain(context, store.head_version);
|
|
101
|
+
return {
|
|
102
|
+
versions: chain.slice(cursor.offset, cursor.offset + limit).map(publicVersion),
|
|
103
|
+
next_cursor: cursor.offset + limit < chain.length ? Buffer.from(JSON.stringify({ head: store.head_version, offset: cursor.offset + limit })).toString("base64url") : null
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function previewVersion(context, adapter, versionId) {
|
|
107
|
+
const store = await requireStore(context);
|
|
108
|
+
if (!store.head_version) throw new UserError("The project version store has no versions.");
|
|
109
|
+
const selected = await requireReachable(context, store.head_version, versionId);
|
|
110
|
+
const [current, historical, blocker] = await Promise.all([
|
|
111
|
+
adapter.readSnapshot(),
|
|
112
|
+
readSnapshot(context, selected),
|
|
113
|
+
mutationBlocker(context)
|
|
114
|
+
]);
|
|
115
|
+
return buildPreview(context, selected, store.head_version, current, historical, blocker ? [blocker] : []);
|
|
116
|
+
}
|
|
117
|
+
async function restoreVersion(context, adapter, versionId, base) {
|
|
118
|
+
const lease = await acquireLease(context, "version_restore");
|
|
119
|
+
try {
|
|
120
|
+
const store = await requireStore(context);
|
|
121
|
+
if (store.head_version !== base.headVersion) throw new UserError("Version history changed. Preview again.");
|
|
122
|
+
const selected = await requireReachable(context, base.headVersion, versionId);
|
|
123
|
+
const [current, historical] = await Promise.all([adapter.readSnapshot(), readSnapshot(context, selected)]);
|
|
124
|
+
if (current.project_revision !== base.projectRevision) {
|
|
125
|
+
throw new UserError("Project files changed. Preview the version again before restoring.");
|
|
126
|
+
}
|
|
127
|
+
await assertSnapshotSafe(historical, context.projectRoot);
|
|
128
|
+
await adapter.restoreSnapshot(historical, base.projectRevision);
|
|
129
|
+
return buildPreview(context, selected, base.headVersion, current, historical, []);
|
|
130
|
+
} finally {
|
|
131
|
+
await releaseLease(context, lease.token);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function prepareVersion(context, adapter, provided) {
|
|
135
|
+
const lease = await acquireLease(context, "publish");
|
|
136
|
+
try {
|
|
137
|
+
const store = await readStore(context, false);
|
|
138
|
+
const snapshot = provided ?? await adapter.readSnapshot();
|
|
139
|
+
await assertSnapshotSafe(snapshot, context.projectRoot);
|
|
140
|
+
if (!store?.enabled) {
|
|
141
|
+
return {
|
|
142
|
+
projectRoot: context.projectRoot,
|
|
143
|
+
storeRoot: context.storeRoot,
|
|
144
|
+
baseHeadVersion: store?.head_version ?? null,
|
|
145
|
+
snapshot,
|
|
146
|
+
treeHash: snapshotTreeHash(snapshot),
|
|
147
|
+
needsVersion: false,
|
|
148
|
+
versioningEnabled: false,
|
|
149
|
+
leaseToken: lease.token
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (!store.head_version) throw new UserError("Enabled project versioning has no baseline version.");
|
|
153
|
+
const head = await readEntry(context, store.head_version);
|
|
154
|
+
const treeHash = snapshotTreeHash(snapshot);
|
|
155
|
+
return {
|
|
156
|
+
projectRoot: context.projectRoot,
|
|
157
|
+
storeRoot: context.storeRoot,
|
|
158
|
+
baseHeadVersion: store.head_version,
|
|
159
|
+
snapshot,
|
|
160
|
+
treeHash,
|
|
161
|
+
needsVersion: head.tree_hash !== treeHash,
|
|
162
|
+
versioningEnabled: true,
|
|
163
|
+
leaseToken: lease.token
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await releaseLease(context, lease.token);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function commitPrepared(context, prepared, message) {
|
|
171
|
+
try {
|
|
172
|
+
await assertLease(context, prepared.leaseToken);
|
|
173
|
+
if (prepared.projectRoot !== context.projectRoot || prepared.storeRoot !== context.storeRoot) {
|
|
174
|
+
throw new UserError("Prepared version belongs to a different project.");
|
|
175
|
+
}
|
|
176
|
+
if (!prepared.versioningEnabled) return null;
|
|
177
|
+
const store = await requireStore(context);
|
|
178
|
+
if (!store.enabled || store.head_version !== prepared.baseHeadVersion) {
|
|
179
|
+
throw new UserError("Project version history changed while Publish was running.");
|
|
180
|
+
}
|
|
181
|
+
if (!prepared.needsVersion) return null;
|
|
182
|
+
return appendVersion(context, store, prepared.snapshot, message);
|
|
183
|
+
} finally {
|
|
184
|
+
await releaseLease(context, prepared.leaseToken);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function appendVersion(context, store, snapshot, message) {
|
|
188
|
+
if (!message.trim() || /[\r\n]/.test(message)) throw new UserError("Version message must be one non-empty line.");
|
|
189
|
+
const files = [...snapshot.files].sort((left, right) => left.path.localeCompare(right.path));
|
|
190
|
+
const manifest = {
|
|
191
|
+
project_revision: snapshot.project_revision,
|
|
192
|
+
canonical_yaml_hash: hash(snapshot.canonical_yaml),
|
|
193
|
+
files: []
|
|
194
|
+
};
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
assertRelativePath(file.path);
|
|
197
|
+
const blobHash = hash(file.content);
|
|
198
|
+
await writeBlob(context, blobHash, file.content);
|
|
199
|
+
manifest.files.push({ path: file.path, mode: file.mode, blob_hash: blobHash, size: file.content.byteLength });
|
|
200
|
+
}
|
|
201
|
+
const yamlHash = hash(snapshot.canonical_yaml);
|
|
202
|
+
await writeBlob(context, yamlHash, new TextEncoder().encode(snapshot.canonical_yaml));
|
|
203
|
+
const manifestSource = JSON.stringify(manifest);
|
|
204
|
+
const manifestHash = hash(manifestSource);
|
|
205
|
+
await writeJsonAtomic(resolve(context.manifestsRoot, `${manifestHash}.json`), manifest);
|
|
206
|
+
const entryBase = {
|
|
207
|
+
parent_version: store.head_version,
|
|
208
|
+
tree_hash: snapshotTreeHash(snapshot),
|
|
209
|
+
yaml_hash: yamlHash,
|
|
210
|
+
manifest_hash: manifestHash,
|
|
211
|
+
message: message.trim(),
|
|
212
|
+
created_by: process.env.USER || process.env.USERNAME || "local-user",
|
|
213
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
214
|
+
nonce: randomUUID()
|
|
215
|
+
};
|
|
216
|
+
const versionId = hash(JSON.stringify(entryBase));
|
|
217
|
+
const entry = {
|
|
218
|
+
version_id: versionId,
|
|
219
|
+
short_version: versionId.slice(0, 12),
|
|
220
|
+
...entryBase
|
|
221
|
+
};
|
|
222
|
+
await writeJsonAtomic(resolve(context.entriesRoot, `${versionId}.json`), entry);
|
|
223
|
+
store.head_version = versionId;
|
|
224
|
+
await writeJsonAtomic(context.storePath, store);
|
|
225
|
+
return publicVersion(entry);
|
|
226
|
+
}
|
|
227
|
+
async function readSnapshot(context, entry) {
|
|
228
|
+
const manifest = await readJson(resolve(context.manifestsRoot, `${entry.manifest_hash}.json`));
|
|
229
|
+
const files = await Promise.all(
|
|
230
|
+
manifest.files.map(async (file) => ({
|
|
231
|
+
path: file.path,
|
|
232
|
+
mode: file.mode,
|
|
233
|
+
content: new Uint8Array(await readFile(resolve(context.blobsRoot, file.blob_hash)))
|
|
234
|
+
}))
|
|
235
|
+
);
|
|
236
|
+
return {
|
|
237
|
+
project_revision: manifest.project_revision,
|
|
238
|
+
canonical_yaml: (await readFile(resolve(context.blobsRoot, entry.yaml_hash))).toString("utf8"),
|
|
239
|
+
files
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async function buildPreview(context, entry, headVersion, current, historical, blockers) {
|
|
243
|
+
const [currentInspection, historicalInspection] = await Promise.all([
|
|
244
|
+
inspectProjectSource(current.canonical_yaml, resolve(context.projectRoot, ".openagentpack/build/agents.yaml")),
|
|
245
|
+
inspectProjectSource(historical.canonical_yaml, resolve(context.projectRoot, ".openagentpack/build/agents.yaml"))
|
|
246
|
+
]);
|
|
247
|
+
const changes = diffSnapshots(current, historical);
|
|
248
|
+
const canRestore = blockers.length === 0 && historicalInspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
249
|
+
return {
|
|
250
|
+
version_id: entry.version_id,
|
|
251
|
+
base_head_version: headVersion,
|
|
252
|
+
base_project_revision: current.project_revision,
|
|
253
|
+
before_yaml: currentInspection.redacted_source,
|
|
254
|
+
after_yaml: historicalInspection.redacted_source,
|
|
255
|
+
changes,
|
|
256
|
+
diagnostics: historicalInspection.diagnostics,
|
|
257
|
+
can_restore: canRestore,
|
|
258
|
+
blockers
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function diffSnapshots(current, historical) {
|
|
262
|
+
const before = new Map(current.files.map((file) => [file.path, file]));
|
|
263
|
+
const after = new Map(historical.files.map((file) => [file.path, file]));
|
|
264
|
+
const paths = [.../* @__PURE__ */ new Set([...before.keys(), ...after.keys()])].sort();
|
|
265
|
+
const changes = [];
|
|
266
|
+
for (const path of paths) {
|
|
267
|
+
const beforeFile = before.get(path);
|
|
268
|
+
const afterFile = after.get(path);
|
|
269
|
+
if (beforeFile && afterFile && hash(beforeFile.content) === hash(afterFile.content) && beforeFile.mode === afterFile.mode) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const beforeText = beforeFile ? decodeText(beforeFile.content) : void 0;
|
|
273
|
+
const afterText = afterFile ? decodeText(afterFile.content) : void 0;
|
|
274
|
+
changes.push({
|
|
275
|
+
path,
|
|
276
|
+
change: !beforeFile ? "create" : !afterFile ? "delete" : "update",
|
|
277
|
+
binary: beforeFile !== void 0 && beforeText === null || afterFile !== void 0 && afterText === null,
|
|
278
|
+
before: beforeText === null ? void 0 : safeDiffText(path, beforeText),
|
|
279
|
+
after: afterText === null ? void 0 : safeDiffText(path, afterText)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return changes;
|
|
283
|
+
}
|
|
284
|
+
function safeDiffText(path, content) {
|
|
285
|
+
if (content === void 0 || !path.toLowerCase().endsWith(".json")) return content;
|
|
286
|
+
try {
|
|
287
|
+
return `${JSON.stringify(redactJson(JSON.parse(content)), null, 2)}
|
|
288
|
+
`;
|
|
289
|
+
} catch {
|
|
290
|
+
return content;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function redactJson(value, key = "") {
|
|
294
|
+
if (Array.isArray(value)) return value.map((entry) => redactJson(entry));
|
|
295
|
+
if (!value || typeof value !== "object") {
|
|
296
|
+
return SENSITIVE_KEY.test(key) && typeof value === "string" && !ENV_REFERENCE.test(value) ? "[redacted]" : value;
|
|
297
|
+
}
|
|
298
|
+
return Object.fromEntries(
|
|
299
|
+
Object.entries(value).map(([entryKey, entry]) => [
|
|
300
|
+
entryKey,
|
|
301
|
+
redactJson(entry, entryKey)
|
|
302
|
+
])
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
function decodeText(content) {
|
|
306
|
+
if (content.includes(0)) return null;
|
|
307
|
+
try {
|
|
308
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function assertSnapshotSafe(snapshot, projectRoot) {
|
|
314
|
+
const inspection = await inspectProjectSource(
|
|
315
|
+
snapshot.canonical_yaml,
|
|
316
|
+
resolve(projectRoot, ".openagentpack/build/agents.yaml")
|
|
317
|
+
);
|
|
318
|
+
const diagnostic = inspection.diagnostics.find((item) => item.severity === "error");
|
|
319
|
+
if (diagnostic) throw new UserError(diagnostic.message);
|
|
320
|
+
for (const file of snapshot.files) assertRelativePath(file.path);
|
|
321
|
+
}
|
|
322
|
+
function snapshotTreeHash(snapshot) {
|
|
323
|
+
const digest = createHash("sha256");
|
|
324
|
+
for (const file of [...snapshot.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
325
|
+
digest.update(file.path).update("\0").update(String(file.mode)).update("\0").update(file.content).update("\0");
|
|
326
|
+
}
|
|
327
|
+
return digest.digest("hex");
|
|
328
|
+
}
|
|
329
|
+
function directoryContext(projectRoot) {
|
|
330
|
+
const normalized = resolve(projectRoot);
|
|
331
|
+
const storeRoot = resolve(normalized, ".openagentpack", "versions", "project");
|
|
332
|
+
return {
|
|
333
|
+
projectRoot: normalized,
|
|
334
|
+
storeRoot,
|
|
335
|
+
storePath: resolve(storeRoot, "store.json"),
|
|
336
|
+
entriesRoot: resolve(storeRoot, "entries"),
|
|
337
|
+
blobsRoot: resolve(storeRoot, "blobs"),
|
|
338
|
+
manifestsRoot: resolve(storeRoot, "manifests"),
|
|
339
|
+
lockRoot: resolve(normalized, ".openagentpack", "mutation.lock"),
|
|
340
|
+
leasePath: resolve(normalized, ".openagentpack", "mutation.lock", "lease.json")
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function emptyStore() {
|
|
344
|
+
return { schema_version: DIRECTORY_STORE_SCHEMA, enabled: false, head_version: null };
|
|
345
|
+
}
|
|
346
|
+
async function readStore(context, required) {
|
|
347
|
+
try {
|
|
348
|
+
const value = await readJson(context.storePath);
|
|
349
|
+
if (value.schema_version !== DIRECTORY_STORE_SCHEMA || typeof value.enabled !== "boolean" || value.head_version !== null && !VERSION_ID.test(value.head_version)) {
|
|
350
|
+
throw new UserError("The project version store is invalid.");
|
|
351
|
+
}
|
|
352
|
+
return value;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (isFsError(error, "ENOENT") && !required) return null;
|
|
355
|
+
if (isFsError(error, "ENOENT")) throw new UserError("No project version store exists. Run project init first.");
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function requireStore(context) {
|
|
360
|
+
return await readStore(context, true);
|
|
361
|
+
}
|
|
362
|
+
async function readEntry(context, versionId) {
|
|
363
|
+
assertVersionId(versionId);
|
|
364
|
+
const entry = await readJson(resolve(context.entriesRoot, `${versionId}.json`));
|
|
365
|
+
if (entry.version_id !== versionId || entry.short_version !== versionId.slice(0, 12)) {
|
|
366
|
+
throw new UserError("A project version entry is invalid.");
|
|
367
|
+
}
|
|
368
|
+
return entry;
|
|
369
|
+
}
|
|
370
|
+
async function readChain(context, headVersion) {
|
|
371
|
+
const result = [];
|
|
372
|
+
const seen = /* @__PURE__ */ new Set();
|
|
373
|
+
let current = headVersion;
|
|
374
|
+
while (current) {
|
|
375
|
+
if (seen.has(current)) throw new UserError("The project version history contains a cycle.");
|
|
376
|
+
seen.add(current);
|
|
377
|
+
const entry = await readEntry(context, current);
|
|
378
|
+
result.push(entry);
|
|
379
|
+
current = entry.parent_version;
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
async function requireReachable(context, headVersion, versionId) {
|
|
384
|
+
assertVersionId(versionId);
|
|
385
|
+
const entry = (await readChain(context, headVersion)).find((candidate) => candidate.version_id === versionId);
|
|
386
|
+
if (!entry) throw new UserError("Project version is not reachable from the current history.");
|
|
387
|
+
return entry;
|
|
388
|
+
}
|
|
389
|
+
function publicVersion(entry) {
|
|
390
|
+
return {
|
|
391
|
+
version_id: entry.version_id,
|
|
392
|
+
short_version: entry.short_version,
|
|
393
|
+
parent_version: entry.parent_version,
|
|
394
|
+
tree_hash: entry.tree_hash,
|
|
395
|
+
yaml_hash: entry.yaml_hash,
|
|
396
|
+
message: entry.message,
|
|
397
|
+
created_by: entry.created_by,
|
|
398
|
+
created_at: entry.created_at
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async function writeBlob(context, blobHash, content) {
|
|
402
|
+
const path = resolve(context.blobsRoot, blobHash);
|
|
403
|
+
try {
|
|
404
|
+
await stat(path);
|
|
405
|
+
return;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!isFsError(error, "ENOENT")) throw error;
|
|
408
|
+
}
|
|
409
|
+
await mkdir(context.blobsRoot, { recursive: true });
|
|
410
|
+
await writeFile(path, content, { flag: "wx" }).catch(async (error) => {
|
|
411
|
+
if (!isFsError(error, "EEXIST")) throw error;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async function readJson(path) {
|
|
415
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
416
|
+
}
|
|
417
|
+
async function writeJsonAtomic(path, value) {
|
|
418
|
+
await mkdir(dirname(path), { recursive: true });
|
|
419
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
420
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
|
|
421
|
+
`, { mode: 384 });
|
|
422
|
+
await rename(temporary, path);
|
|
423
|
+
}
|
|
424
|
+
async function acquireLease(context, kind) {
|
|
425
|
+
await mkdir(dirname(context.lockRoot), { recursive: true });
|
|
426
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
427
|
+
try {
|
|
428
|
+
await mkdir(context.lockRoot);
|
|
429
|
+
break;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (!isFsError(error, "EEXIST")) throw error;
|
|
432
|
+
if (attempt === 0 && await recoverDeadLease(context)) continue;
|
|
433
|
+
throw new DirectoryProjectMutationConflictError(await mutationBlocker(context) ?? "Project is busy.");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const lease = { token: randomUUID(), pid: process.pid, kind, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
437
|
+
try {
|
|
438
|
+
await writeJsonAtomic(context.leasePath, lease);
|
|
439
|
+
return lease;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
await rm(context.lockRoot, { recursive: true, force: true });
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function assertLease(context, token) {
|
|
446
|
+
const lease = await readJson(context.leasePath);
|
|
447
|
+
if (lease.token !== token) throw new UserError("Project mutation lease changed.");
|
|
448
|
+
}
|
|
449
|
+
async function releaseLease(context, token) {
|
|
450
|
+
try {
|
|
451
|
+
await assertLease(context, token);
|
|
452
|
+
await rm(context.lockRoot, { recursive: true, force: true });
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!isFsError(error, "ENOENT")) throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function mutationBlocker(context) {
|
|
458
|
+
try {
|
|
459
|
+
const lease = await readJson(context.leasePath);
|
|
460
|
+
if (!isProcessAlive(lease.pid) && await recoverDeadLease(context)) return null;
|
|
461
|
+
return `Project is busy with ${lease.kind} (pid ${lease.pid}).`;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return isFsError(error, "ENOENT") ? null : "Project mutation lock is unreadable.";
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function recoverDeadLease(context) {
|
|
467
|
+
let pid;
|
|
468
|
+
try {
|
|
469
|
+
const lease = await readJson(context.leasePath);
|
|
470
|
+
if (typeof lease.pid !== "number" || !Number.isSafeInteger(lease.pid) || lease.pid <= 0) return false;
|
|
471
|
+
pid = lease.pid;
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (isProcessAlive(pid)) return false;
|
|
476
|
+
const stale = `${context.lockRoot}.stale.${randomUUID()}`;
|
|
477
|
+
try {
|
|
478
|
+
await rename(context.lockRoot, stale);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return isFsError(error, "ENOENT");
|
|
481
|
+
}
|
|
482
|
+
await rm(stale, { recursive: true, force: true });
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
function isProcessAlive(pid) {
|
|
486
|
+
try {
|
|
487
|
+
process.kill(pid, 0);
|
|
488
|
+
return true;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return !isFsError(error, "ESRCH");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function decodeCursor(cursor) {
|
|
494
|
+
try {
|
|
495
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
496
|
+
if (value.head !== null && (typeof value.head !== "string" || !VERSION_ID.test(value.head)) || !Number.isInteger(value.offset) || Number(value.offset) < 0) {
|
|
497
|
+
throw new Error("invalid");
|
|
498
|
+
}
|
|
499
|
+
return { head: value.head, offset: Number(value.offset) };
|
|
500
|
+
} catch {
|
|
501
|
+
throw new UserError("Invalid project version cursor.");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function hash(content) {
|
|
505
|
+
return createHash("sha256").update(content).digest("hex");
|
|
506
|
+
}
|
|
507
|
+
function assertRelativePath(path) {
|
|
508
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
509
|
+
throw new UserError(`Invalid project snapshot path: ${path}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function assertVersionId(versionId) {
|
|
513
|
+
if (!VERSION_ID.test(versionId)) throw new UserError("Project version must be a full 64-character SHA-256 id.");
|
|
514
|
+
}
|
|
515
|
+
function isFsError(error, code) {
|
|
516
|
+
return !!error && typeof error === "object" && "code" in error && error.code === code;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/internal/project-versions/index.ts
|
|
520
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
521
|
+
import { constants } from "fs";
|
|
522
|
+
import { access, chmod, mkdir as mkdir2, open, readFile as readFile2, realpath, rename as rename2, rmdir, stat as stat2, unlink } from "fs/promises";
|
|
523
|
+
import { basename, dirname as dirname2, relative, resolve as resolve2 } from "path";
|
|
524
|
+
var STORE_SCHEMA_VERSION = 1;
|
|
525
|
+
var SELF_IGNORE_SOURCE = "*\n";
|
|
526
|
+
var ProjectVersionError = class extends UserError {
|
|
527
|
+
constructor(message, code = "storage_failed") {
|
|
528
|
+
super(message);
|
|
529
|
+
this.code = code;
|
|
530
|
+
this.name = "ProjectVersionError";
|
|
531
|
+
}
|
|
532
|
+
code;
|
|
533
|
+
};
|
|
534
|
+
var UserError2 = ProjectVersionError;
|
|
535
|
+
async function readProjectVersionSource(configFile) {
|
|
536
|
+
const configPath = await resolveConfigPath(configFile);
|
|
537
|
+
return { configPath, source: await readFile2(configPath, "utf8") };
|
|
538
|
+
}
|
|
539
|
+
async function getProjectVersionStatus(configFile) {
|
|
540
|
+
const configPath = await resolveConfigPath(configFile);
|
|
541
|
+
const context = storeContext(configPath);
|
|
542
|
+
const store = await readStore2(context, false);
|
|
543
|
+
if (!store) return absentStatus(context);
|
|
544
|
+
const [source, blocker] = await Promise.all([readFile2(configPath, "utf8"), mutationBlocker2(context)]);
|
|
545
|
+
return publicStatus2(context, store, source, blocker ? [blocker] : []);
|
|
546
|
+
}
|
|
547
|
+
async function enableProjectVersioning(configFile, message = "Enable OpenAgentPack versioning") {
|
|
548
|
+
const { configPath, source } = await readProjectVersionSource(configFile);
|
|
549
|
+
await assertValidVersionSource(source, configPath);
|
|
550
|
+
const context = storeContext(configPath);
|
|
551
|
+
const lease = await acquireMutationLease(context, "version_enable");
|
|
552
|
+
try {
|
|
553
|
+
const store = await readStore2(context, false) ?? emptyStore2(context);
|
|
554
|
+
const head = await readHeadVersion(context, store);
|
|
555
|
+
store.enabled = true;
|
|
556
|
+
const version = head?.source_hash === sourceRevision(source) ? null : await appendVersion2(context, store, source, message);
|
|
557
|
+
if (!version) await writeStore(context, store);
|
|
558
|
+
return { version, versioning: await publicStatus2(context, store, source) };
|
|
559
|
+
} finally {
|
|
560
|
+
await releaseMutationLease(context, lease.token);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function disableProjectVersioning(configFile) {
|
|
564
|
+
const configPath = await resolveConfigPath(configFile);
|
|
565
|
+
const context = storeContext(configPath);
|
|
566
|
+
const existing = await readStore2(context, false);
|
|
567
|
+
if (!existing) return absentStatus(context);
|
|
568
|
+
const lease = await acquireMutationLease(context, "version_disable");
|
|
569
|
+
try {
|
|
570
|
+
const store = await requireStore2(context);
|
|
571
|
+
store.enabled = false;
|
|
572
|
+
await writeStore(context, store);
|
|
573
|
+
return await publicStatus2(context, store, await readFile2(configPath, "utf8"));
|
|
574
|
+
} finally {
|
|
575
|
+
await releaseMutationLease(context, lease.token);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function listProjectVersions(configFile, input = {}) {
|
|
579
|
+
const configPath = await resolveConfigPath(configFile);
|
|
580
|
+
const context = storeContext(configPath);
|
|
581
|
+
const store = await requireStore2(context);
|
|
582
|
+
const { offset, headVersion } = parseCursor(input.cursor, store.head_version);
|
|
583
|
+
if (headVersion !== store.head_version) {
|
|
584
|
+
throw new UserError2("Version history changed. Restart pagination from the first page.", "stale_snapshot");
|
|
585
|
+
}
|
|
586
|
+
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
|
|
587
|
+
const versions = await readVersionChain(context, store.head_version);
|
|
588
|
+
return {
|
|
589
|
+
versions: versions.slice(offset, offset + limit).map(publicVersion2),
|
|
590
|
+
next_cursor: offset + limit < versions.length ? encodeCursor({ headVersion: store.head_version, offset: offset + limit }) : null
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function previewProjectVersion(configFile, versionId) {
|
|
594
|
+
const configPath = await resolveConfigPath(configFile);
|
|
595
|
+
const context = storeContext(configPath);
|
|
596
|
+
const store = await requireStore2(context);
|
|
597
|
+
if (!store.head_version) throw new UserError2("The local version store has no versions.", "invalid_version");
|
|
598
|
+
const selected = await requireReachableVersion(context, store.head_version, versionId);
|
|
599
|
+
const [currentSource, historicalSource, blocker] = await Promise.all([
|
|
600
|
+
readFile2(configPath, "utf8"),
|
|
601
|
+
readBlob(context, selected),
|
|
602
|
+
mutationBlocker2(context)
|
|
603
|
+
]);
|
|
604
|
+
const [currentInspection, historicalInspection] = await Promise.all([
|
|
605
|
+
inspectProjectSource(currentSource, configPath),
|
|
606
|
+
inspectProjectSource(historicalSource, configPath)
|
|
607
|
+
]);
|
|
608
|
+
const blockers = blocker ? [blocker] : [];
|
|
609
|
+
return {
|
|
610
|
+
version_id: selected.version_id,
|
|
611
|
+
base_head_version: store.head_version,
|
|
612
|
+
base_source_revision: sourceRevision(currentSource),
|
|
613
|
+
before_yaml: currentInspection.redacted_source,
|
|
614
|
+
after_yaml: historicalInspection.redacted_source,
|
|
615
|
+
diagnostics: historicalInspection.diagnostics,
|
|
616
|
+
can_restore: historicalInspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error") && blockers.length === 0,
|
|
617
|
+
blockers
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
async function restoreProjectVersion(configFile, versionId, base) {
|
|
621
|
+
const configPath = await resolveConfigPath(configFile);
|
|
622
|
+
const context = storeContext(configPath);
|
|
623
|
+
const lease = await acquireMutationLease(context, "version_restore");
|
|
624
|
+
try {
|
|
625
|
+
const store = await requireStore2(context);
|
|
626
|
+
assertHeadVersion(base.headVersion, store.head_version);
|
|
627
|
+
const selected = await requireReachableVersion(context, base.headVersion, versionId);
|
|
628
|
+
const currentSource = await readFile2(configPath, "utf8");
|
|
629
|
+
assertSourceRevision(base.sourceRevision, currentSource);
|
|
630
|
+
const historicalSource = await readBlob(context, selected);
|
|
631
|
+
const [currentInspection, historicalInspection] = await Promise.all([
|
|
632
|
+
inspectProjectSource(currentSource, configPath),
|
|
633
|
+
inspectProjectSource(historicalSource, configPath)
|
|
634
|
+
]);
|
|
635
|
+
const validationError = historicalInspection.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
636
|
+
if (validationError) throw new UserError2(validationError.message, "invalid_source");
|
|
637
|
+
await atomicWriteConfig(configPath, historicalSource, base.sourceRevision);
|
|
638
|
+
return {
|
|
639
|
+
version_id: selected.version_id,
|
|
640
|
+
base_head_version: base.headVersion,
|
|
641
|
+
base_source_revision: base.sourceRevision,
|
|
642
|
+
before_yaml: currentInspection.redacted_source,
|
|
643
|
+
after_yaml: historicalInspection.redacted_source,
|
|
644
|
+
diagnostics: historicalInspection.diagnostics,
|
|
645
|
+
can_restore: true,
|
|
646
|
+
blockers: []
|
|
647
|
+
};
|
|
648
|
+
} finally {
|
|
649
|
+
await releaseMutationLease(context, lease.token);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function prepareProjectVersion(configFile, expectedSource) {
|
|
653
|
+
const configPath = await resolveConfigPath(configFile);
|
|
654
|
+
const context = storeContext(configPath);
|
|
655
|
+
const initialStore = await readStore2(context, false);
|
|
656
|
+
if (!initialStore?.enabled) return null;
|
|
657
|
+
const lease = await acquireMutationLease(context, "apply");
|
|
658
|
+
try {
|
|
659
|
+
const store = await requireStore2(context);
|
|
660
|
+
if (!store.enabled || !store.head_version) {
|
|
661
|
+
throw new UserError2("Automatic versioning changed while Apply was starting.", "stale_snapshot");
|
|
662
|
+
}
|
|
663
|
+
const source = await readFile2(configPath, "utf8");
|
|
664
|
+
if (source !== expectedSource) {
|
|
665
|
+
throw new UserError2(
|
|
666
|
+
"agents.yaml changed while Apply was being planned. Rerun Apply with the current file.",
|
|
667
|
+
"stale_snapshot"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
await assertValidVersionSource(source, configPath);
|
|
671
|
+
const head = await readEntry2(context, store.head_version);
|
|
672
|
+
return {
|
|
673
|
+
configPath,
|
|
674
|
+
storeRoot: context.storeRoot,
|
|
675
|
+
baseHeadVersion: store.head_version,
|
|
676
|
+
source,
|
|
677
|
+
sourceRevision: sourceRevision(source),
|
|
678
|
+
needsVersion: head.source_hash !== sourceRevision(source),
|
|
679
|
+
leaseToken: lease.token
|
|
680
|
+
};
|
|
681
|
+
} catch (error) {
|
|
682
|
+
await releaseMutationLease(context, lease.token);
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function commitPreparedProjectVersion(prepared, message = "Apply agents.yaml") {
|
|
687
|
+
const context = storeContext(prepared.configPath);
|
|
688
|
+
if (context.storeRoot !== prepared.storeRoot) {
|
|
689
|
+
throw postApplyVersionError("the local version store location changed");
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
await assertMutationLease(context, prepared.leaseToken);
|
|
693
|
+
const currentSource = await readFile2(prepared.configPath, "utf8");
|
|
694
|
+
if (currentSource !== prepared.source || sourceRevision(currentSource) !== prepared.sourceRevision) {
|
|
695
|
+
throw new UserError2("agents.yaml changed while Apply was running", "stale_snapshot");
|
|
696
|
+
}
|
|
697
|
+
const store = await requireStore2(context);
|
|
698
|
+
if (!store.enabled) {
|
|
699
|
+
throw new UserError2("automatic versioning was disabled while Apply was running", "stale_snapshot");
|
|
700
|
+
}
|
|
701
|
+
assertHeadVersion(prepared.baseHeadVersion, store.head_version);
|
|
702
|
+
const head = await readEntry2(context, prepared.baseHeadVersion);
|
|
703
|
+
if (!prepared.needsVersion && head.source_hash === prepared.sourceRevision) return null;
|
|
704
|
+
return await appendVersion2(context, store, prepared.source, message);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (error instanceof ProjectVersionError && error.message.startsWith("Remote Apply completed")) throw error;
|
|
707
|
+
throw postApplyVersionError(errorMessage(error));
|
|
708
|
+
} finally {
|
|
709
|
+
await releaseMutationLease(context, prepared.leaseToken).catch(() => void 0);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async function releasePreparedProjectVersion(prepared) {
|
|
713
|
+
if (!prepared) return;
|
|
714
|
+
await releaseMutationLease(storeContext(prepared.configPath), prepared.leaseToken);
|
|
715
|
+
}
|
|
716
|
+
function createProjectVersionService(input) {
|
|
717
|
+
const configPath = input.configPath;
|
|
718
|
+
return {
|
|
719
|
+
readSource: () => readProjectVersionSource(configPath),
|
|
720
|
+
status: () => getProjectVersionStatus(configPath),
|
|
721
|
+
enable: (message) => enableProjectVersioning(configPath, message),
|
|
722
|
+
disable: () => disableProjectVersioning(configPath),
|
|
723
|
+
listVersions: (options) => listProjectVersions(configPath, options),
|
|
724
|
+
previewVersion: (versionId) => previewProjectVersion(configPath, versionId),
|
|
725
|
+
restoreVersion: (versionId, base) => restoreProjectVersion(configPath, versionId, base),
|
|
726
|
+
prepareVersion: (expectedSource) => prepareProjectVersion(configPath, expectedSource),
|
|
727
|
+
commitPrepared: (prepared, message) => commitPreparedProjectVersion(prepared, message),
|
|
728
|
+
releasePrepared: (prepared) => releasePreparedProjectVersion(prepared)
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
async function appendVersion2(context, store, source, message) {
|
|
732
|
+
assertVersionMessage(message);
|
|
733
|
+
const sourceHash = sourceRevision(source);
|
|
734
|
+
await writeBlob2(context, sourceHash, source);
|
|
735
|
+
const entryWithoutId = {
|
|
736
|
+
parent_version: store.head_version,
|
|
737
|
+
source_hash: sourceHash,
|
|
738
|
+
message,
|
|
739
|
+
created_by: localAuthorName(),
|
|
740
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
741
|
+
nonce: randomUUID2()
|
|
742
|
+
};
|
|
743
|
+
const versionId = createHash2("sha256").update(JSON.stringify(entryWithoutId)).digest("hex");
|
|
744
|
+
const version = {
|
|
745
|
+
version_id: versionId,
|
|
746
|
+
short_version: versionId.slice(0, 12),
|
|
747
|
+
...entryWithoutId
|
|
748
|
+
};
|
|
749
|
+
await writeEntry(context, version);
|
|
750
|
+
store.head_version = version.version_id;
|
|
751
|
+
await writeStore(context, store);
|
|
752
|
+
return publicVersion2(version);
|
|
753
|
+
}
|
|
754
|
+
function publicVersion2(version) {
|
|
755
|
+
const { nonce: _nonce, ...publicEntry } = version;
|
|
756
|
+
return publicEntry;
|
|
757
|
+
}
|
|
758
|
+
function storeContext(configPath) {
|
|
759
|
+
const storeRoot = resolve2(dirname2(configPath), ".openagentpack", "versions");
|
|
760
|
+
return {
|
|
761
|
+
configPath,
|
|
762
|
+
storeRoot,
|
|
763
|
+
storePath: resolve2(storeRoot, "store.json"),
|
|
764
|
+
entriesRoot: resolve2(storeRoot, "entries"),
|
|
765
|
+
blobsRoot: resolve2(storeRoot, "blobs"),
|
|
766
|
+
ignorePath: resolve2(storeRoot, ".gitignore"),
|
|
767
|
+
lockPath: resolve2(storeRoot, "mutation.lock"),
|
|
768
|
+
leasePath: resolve2(storeRoot, "mutation.lock", "lease.json"),
|
|
769
|
+
configRelativePath: relative(dirname2(configPath), configPath) || basename(configPath)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function emptyStore2(context) {
|
|
773
|
+
return {
|
|
774
|
+
schema_version: STORE_SCHEMA_VERSION,
|
|
775
|
+
config_path: context.configRelativePath,
|
|
776
|
+
enabled: false,
|
|
777
|
+
head_version: null
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
async function readStore2(context, required) {
|
|
781
|
+
let source;
|
|
782
|
+
try {
|
|
783
|
+
source = await readFile2(context.storePath, "utf8");
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (isFileSystemError(error, "ENOENT") && !required) return null;
|
|
786
|
+
if (isFileSystemError(error, "ENOENT")) {
|
|
787
|
+
throw new UserError2(
|
|
788
|
+
"No local version store exists for agents.yaml. Enable local versions first.",
|
|
789
|
+
"store_missing"
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
throw new UserError2(`Cannot read the local version store: ${errorMessage(error)}`, "storage_failed");
|
|
793
|
+
}
|
|
794
|
+
let parsed;
|
|
795
|
+
try {
|
|
796
|
+
parsed = JSON.parse(source);
|
|
797
|
+
} catch {
|
|
798
|
+
throw new UserError2("The local version store is not valid JSON.", "storage_failed");
|
|
799
|
+
}
|
|
800
|
+
return parseStore(parsed, context);
|
|
801
|
+
}
|
|
802
|
+
async function requireStore2(context) {
|
|
803
|
+
return await readStore2(context, true);
|
|
804
|
+
}
|
|
805
|
+
function parseStore(value, context) {
|
|
806
|
+
if (!isRecord(value) || value.schema_version !== STORE_SCHEMA_VERSION) {
|
|
807
|
+
throw new UserError2("The local version store uses an unsupported schema.", "storage_failed");
|
|
808
|
+
}
|
|
809
|
+
if (value.config_path !== context.configRelativePath || typeof value.enabled !== "boolean") {
|
|
810
|
+
throw new UserError2("The local version store does not belong to this agents.yaml.", "storage_failed");
|
|
811
|
+
}
|
|
812
|
+
if (value.head_version !== null && !isVersionId(value.head_version)) {
|
|
813
|
+
throw new UserError2("The local version store has an invalid head version.", "storage_failed");
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
schema_version: STORE_SCHEMA_VERSION,
|
|
817
|
+
config_path: value.config_path,
|
|
818
|
+
enabled: value.enabled,
|
|
819
|
+
head_version: value.head_version
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
async function readHeadVersion(context, store) {
|
|
823
|
+
return store.head_version ? readEntry2(context, store.head_version) : null;
|
|
824
|
+
}
|
|
825
|
+
async function readVersionChain(context, headVersion) {
|
|
826
|
+
const versions = [];
|
|
827
|
+
const seen = /* @__PURE__ */ new Set();
|
|
828
|
+
let currentVersion = headVersion;
|
|
829
|
+
while (currentVersion) {
|
|
830
|
+
if (seen.has(currentVersion)) {
|
|
831
|
+
throw new UserError2("The local version history contains a cycle.", "storage_failed");
|
|
832
|
+
}
|
|
833
|
+
seen.add(currentVersion);
|
|
834
|
+
const entry = await readEntry2(context, currentVersion);
|
|
835
|
+
versions.push(entry);
|
|
836
|
+
currentVersion = entry.parent_version;
|
|
837
|
+
}
|
|
838
|
+
return versions;
|
|
839
|
+
}
|
|
840
|
+
async function requireReachableVersion(context, headVersion, versionId) {
|
|
841
|
+
assertVersionId2(versionId);
|
|
842
|
+
const versions = await readVersionChain(context, headVersion);
|
|
843
|
+
const selected = versions.find((entry) => entry.version_id === versionId);
|
|
844
|
+
if (!selected) throw new UserError2("Local version was not found in the current history.", "invalid_version");
|
|
845
|
+
return selected;
|
|
846
|
+
}
|
|
847
|
+
async function readEntry2(context, versionId) {
|
|
848
|
+
assertVersionId2(versionId);
|
|
849
|
+
let source;
|
|
850
|
+
try {
|
|
851
|
+
source = await readFile2(resolve2(context.entriesRoot, `${versionId}.json`), "utf8");
|
|
852
|
+
} catch (error) {
|
|
853
|
+
if (isFileSystemError(error, "ENOENT")) {
|
|
854
|
+
throw new UserError2("A version entry in the local history is missing.", "storage_failed");
|
|
855
|
+
}
|
|
856
|
+
throw new UserError2(`Cannot read a version entry: ${errorMessage(error)}`, "storage_failed");
|
|
857
|
+
}
|
|
858
|
+
let parsed;
|
|
859
|
+
try {
|
|
860
|
+
parsed = JSON.parse(source);
|
|
861
|
+
} catch {
|
|
862
|
+
throw new UserError2("A local version entry is not valid JSON.", "storage_failed");
|
|
863
|
+
}
|
|
864
|
+
return parseEntry(parsed, versionId);
|
|
865
|
+
}
|
|
866
|
+
function parseEntry(value, expectedVersionId) {
|
|
867
|
+
if (!isRecord(value) || value.version_id !== expectedVersionId || value.short_version !== expectedVersionId.slice(0, 12) || value.parent_version !== null && !isVersionId(value.parent_version) || !isVersionId(value.source_hash) || typeof value.message !== "string" || typeof value.created_by !== "string" || typeof value.created_at !== "string" || typeof value.nonce !== "string") {
|
|
868
|
+
throw new UserError2("The local version history contains an invalid entry.", "storage_failed");
|
|
869
|
+
}
|
|
870
|
+
const identity = {
|
|
871
|
+
parent_version: value.parent_version,
|
|
872
|
+
source_hash: value.source_hash,
|
|
873
|
+
message: value.message,
|
|
874
|
+
created_by: value.created_by,
|
|
875
|
+
created_at: value.created_at,
|
|
876
|
+
nonce: value.nonce
|
|
877
|
+
};
|
|
878
|
+
if (createHash2("sha256").update(JSON.stringify(identity)).digest("hex") !== expectedVersionId) {
|
|
879
|
+
throw new UserError2("A local version entry failed its identity check.", "storage_failed");
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
version_id: expectedVersionId,
|
|
883
|
+
short_version: expectedVersionId.slice(0, 12),
|
|
884
|
+
...identity
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
async function writeStore(context, store) {
|
|
888
|
+
await ensureStoreLayout(context);
|
|
889
|
+
await atomicWrite(context.storePath, `${JSON.stringify(store, null, 2)}
|
|
890
|
+
`, 384);
|
|
891
|
+
}
|
|
892
|
+
async function writeEntry(context, version) {
|
|
893
|
+
await ensureStoreLayout(context);
|
|
894
|
+
await writeImmutable(
|
|
895
|
+
resolve2(context.entriesRoot, `${version.version_id}.json`),
|
|
896
|
+
`${JSON.stringify(version, null, 2)}
|
|
897
|
+
`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
async function writeBlob2(context, sourceHash, source) {
|
|
901
|
+
await ensureStoreLayout(context);
|
|
902
|
+
await writeImmutable(resolve2(context.blobsRoot, `${sourceHash}.yaml`), source);
|
|
903
|
+
}
|
|
904
|
+
async function readBlob(context, version) {
|
|
905
|
+
let source;
|
|
906
|
+
try {
|
|
907
|
+
source = await readFile2(resolve2(context.blobsRoot, `${version.source_hash}.yaml`), "utf8");
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (isFileSystemError(error, "ENOENT")) {
|
|
910
|
+
throw new UserError2("The YAML blob for this version is missing.", "invalid_version");
|
|
911
|
+
}
|
|
912
|
+
throw new UserError2(`Cannot read the YAML blob: ${errorMessage(error)}`, "storage_failed");
|
|
913
|
+
}
|
|
914
|
+
if (sourceRevision(source) !== version.source_hash) {
|
|
915
|
+
throw new UserError2("The YAML blob for this version failed its content hash check.", "invalid_version");
|
|
916
|
+
}
|
|
917
|
+
return source;
|
|
918
|
+
}
|
|
919
|
+
async function ensureStoreLayout(context) {
|
|
920
|
+
await mkdir2(context.entriesRoot, { recursive: true, mode: 448 });
|
|
921
|
+
await mkdir2(context.blobsRoot, { recursive: true, mode: 448 });
|
|
922
|
+
try {
|
|
923
|
+
await access(context.ignorePath, constants.F_OK);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
if (!isFileSystemError(error, "ENOENT")) throw error;
|
|
926
|
+
await writeImmutable(context.ignorePath, SELF_IGNORE_SOURCE);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function writeImmutable(path, source) {
|
|
930
|
+
try {
|
|
931
|
+
const existing = await readFile2(path, "utf8");
|
|
932
|
+
if (existing !== source) {
|
|
933
|
+
throw new UserError2("An immutable local version object has conflicting content.", "storage_failed");
|
|
934
|
+
}
|
|
935
|
+
return;
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (!isFileSystemError(error, "ENOENT")) throw error;
|
|
938
|
+
}
|
|
939
|
+
await atomicWrite(path, source, 384);
|
|
940
|
+
}
|
|
941
|
+
async function acquireMutationLease(context, kind) {
|
|
942
|
+
await ensureStoreLayout(context);
|
|
943
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
944
|
+
try {
|
|
945
|
+
await mkdir2(context.lockPath, { mode: 448 });
|
|
946
|
+
const lease = {
|
|
947
|
+
pid: process.pid,
|
|
948
|
+
token: randomUUID2(),
|
|
949
|
+
kind,
|
|
950
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
951
|
+
};
|
|
952
|
+
await atomicWrite(context.leasePath, `${JSON.stringify(lease, null, 2)}
|
|
953
|
+
`, 384);
|
|
954
|
+
return lease;
|
|
955
|
+
} catch (error) {
|
|
956
|
+
if (!isFileSystemError(error, "EEXIST")) {
|
|
957
|
+
throw new UserError2(`Cannot lock the local version store: ${errorMessage(error)}`, "storage_failed");
|
|
958
|
+
}
|
|
959
|
+
if (await recoverDeadLease2(context)) continue;
|
|
960
|
+
throw new UserError2(lockBlocker(context), "store_blocked");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
throw new UserError2(lockBlocker(context), "store_blocked");
|
|
964
|
+
}
|
|
965
|
+
async function assertMutationLease(context, token) {
|
|
966
|
+
const lease = await readMutationLease(context);
|
|
967
|
+
if (!lease || lease.token !== token || lease.pid !== process.pid) {
|
|
968
|
+
throw new UserError2("The local version mutation lease changed while Apply was running.", "stale_snapshot");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async function releaseMutationLease(context, token) {
|
|
972
|
+
const lease = await readMutationLease(context);
|
|
973
|
+
if (!lease) return;
|
|
974
|
+
if (lease.token !== token || lease.pid !== process.pid) {
|
|
975
|
+
throw new UserError2("Refusing to release a local version lease owned by another process.", "store_blocked");
|
|
976
|
+
}
|
|
977
|
+
await unlink(context.leasePath);
|
|
978
|
+
await rmdir(context.lockPath);
|
|
979
|
+
await syncDirectory(context.storeRoot);
|
|
980
|
+
}
|
|
981
|
+
async function readMutationLease(context) {
|
|
982
|
+
let source;
|
|
983
|
+
try {
|
|
984
|
+
source = await readFile2(context.leasePath, "utf8");
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (isFileSystemError(error, "ENOENT")) return null;
|
|
987
|
+
throw new UserError2(`Cannot read the local version lease: ${errorMessage(error)}`, "storage_failed");
|
|
988
|
+
}
|
|
989
|
+
let parsed;
|
|
990
|
+
try {
|
|
991
|
+
parsed = JSON.parse(source);
|
|
992
|
+
} catch {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
if (!isRecord(parsed) || typeof parsed.pid !== "number" || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.token !== "string" || typeof parsed.kind !== "string" || typeof parsed.created_at !== "string") {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
pid: parsed.pid,
|
|
1000
|
+
token: parsed.token,
|
|
1001
|
+
kind: parsed.kind,
|
|
1002
|
+
created_at: parsed.created_at
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
async function recoverDeadLease2(context) {
|
|
1006
|
+
const lease = await readMutationLease(context);
|
|
1007
|
+
if (!lease || isProcessAlive2(lease.pid)) return false;
|
|
1008
|
+
const stalePath = `${context.lockPath}.stale.${lease.token}`;
|
|
1009
|
+
try {
|
|
1010
|
+
await rename2(context.lockPath, stalePath);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (isFileSystemError(error, "ENOENT")) return true;
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
await unlink(resolve2(stalePath, "lease.json"));
|
|
1017
|
+
await rmdir(stalePath);
|
|
1018
|
+
await syncDirectory(context.storeRoot);
|
|
1019
|
+
return true;
|
|
1020
|
+
} catch {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function isProcessAlive2(pid) {
|
|
1025
|
+
try {
|
|
1026
|
+
process.kill(pid, 0);
|
|
1027
|
+
return true;
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
return !isFileSystemError(error, "ESRCH");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function mutationBlocker2(context) {
|
|
1033
|
+
try {
|
|
1034
|
+
await access(context.lockPath, constants.F_OK);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
if (isFileSystemError(error, "ENOENT")) return null;
|
|
1037
|
+
throw new UserError2(`Cannot inspect the local version lease: ${errorMessage(error)}`, "storage_failed");
|
|
1038
|
+
}
|
|
1039
|
+
if (await recoverDeadLease2(context)) return null;
|
|
1040
|
+
return lockBlocker(context);
|
|
1041
|
+
}
|
|
1042
|
+
function lockBlocker(context) {
|
|
1043
|
+
return `Another process is changing this project. Wait for it to finish. Lock: ${context.lockPath}`;
|
|
1044
|
+
}
|
|
1045
|
+
async function publicStatus2(context, store, source, blockers = []) {
|
|
1046
|
+
const head = await readHeadVersion(context, store);
|
|
1047
|
+
const sourceVersioned = head?.source_hash === sourceRevision(source);
|
|
1048
|
+
return {
|
|
1049
|
+
initialized: true,
|
|
1050
|
+
enabled: store.enabled,
|
|
1051
|
+
store_root: context.storeRoot,
|
|
1052
|
+
config_path: context.configRelativePath,
|
|
1053
|
+
head_version: store.head_version,
|
|
1054
|
+
source_status: head ? sourceVersioned ? "clean" : "modified" : "unversioned",
|
|
1055
|
+
source_versioned: sourceVersioned,
|
|
1056
|
+
write_blockers: blockers,
|
|
1057
|
+
restore_blockers: blockers
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function absentStatus(context) {
|
|
1061
|
+
return {
|
|
1062
|
+
initialized: false,
|
|
1063
|
+
enabled: false,
|
|
1064
|
+
store_root: context.storeRoot,
|
|
1065
|
+
config_path: context.configRelativePath,
|
|
1066
|
+
head_version: null,
|
|
1067
|
+
source_status: "unversioned",
|
|
1068
|
+
source_versioned: false,
|
|
1069
|
+
write_blockers: [],
|
|
1070
|
+
restore_blockers: []
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
async function assertValidVersionSource(source, configPath) {
|
|
1074
|
+
const inspection = await inspectProjectSource(source, configPath);
|
|
1075
|
+
const validationError = inspection.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
1076
|
+
if (validationError) throw new UserError2(validationError.message, "invalid_source");
|
|
1077
|
+
}
|
|
1078
|
+
async function resolveConfigPath(configFile) {
|
|
1079
|
+
try {
|
|
1080
|
+
return await realpath(resolve2(configFile));
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
throw new UserError2(`Cannot read agents.yaml: ${errorMessage(error)}`, "invalid_source");
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
async function atomicWriteConfig(configPath, source, expectedRevision) {
|
|
1086
|
+
const currentSource = await readFile2(configPath, "utf8");
|
|
1087
|
+
assertSourceRevision(expectedRevision, currentSource);
|
|
1088
|
+
const fileStat = await stat2(configPath);
|
|
1089
|
+
await atomicWrite(configPath, source, fileStat.mode & 511);
|
|
1090
|
+
}
|
|
1091
|
+
async function atomicWrite(path, source, mode) {
|
|
1092
|
+
const temporaryPath = resolve2(dirname2(path), `.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`);
|
|
1093
|
+
const handle = await open(temporaryPath, "wx", mode);
|
|
1094
|
+
try {
|
|
1095
|
+
await handle.writeFile(source, "utf8");
|
|
1096
|
+
await handle.sync();
|
|
1097
|
+
await chmod(temporaryPath, mode);
|
|
1098
|
+
} finally {
|
|
1099
|
+
await handle.close();
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
await rename2(temporaryPath, path);
|
|
1103
|
+
await syncDirectory(dirname2(path));
|
|
1104
|
+
} finally {
|
|
1105
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
async function syncDirectory(directory) {
|
|
1109
|
+
const handle = await open(directory, "r");
|
|
1110
|
+
try {
|
|
1111
|
+
await handle.sync();
|
|
1112
|
+
} finally {
|
|
1113
|
+
await handle.close();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function assertHeadVersion(expected, current) {
|
|
1117
|
+
if (expected !== current) {
|
|
1118
|
+
throw new UserError2("The current local version changed. Reload versions and retry.", "stale_snapshot");
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function assertSourceRevision(expected, source) {
|
|
1122
|
+
if (sourceRevision(source) !== expected) {
|
|
1123
|
+
throw new UserError2("agents.yaml changed. Preview the version again before restoring.", "stale_snapshot");
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function sourceRevision(source) {
|
|
1127
|
+
return createHash2("sha256").update(source).digest("hex");
|
|
1128
|
+
}
|
|
1129
|
+
function assertVersionId2(versionId) {
|
|
1130
|
+
if (!isVersionId(versionId)) {
|
|
1131
|
+
throw new UserError2("Version ID must be a full 64-character hexadecimal value.", "invalid_version");
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function isVersionId(value) {
|
|
1135
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
1136
|
+
}
|
|
1137
|
+
function assertVersionMessage(message) {
|
|
1138
|
+
if (!message.trim() || message !== message.trim() || message.length > 120 || /[\r\n]/.test(message)) {
|
|
1139
|
+
throw new UserError2("Version message must be one trimmed line between 1 and 120 characters.", "invalid_source");
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function encodeCursor(input) {
|
|
1143
|
+
return Buffer.from(JSON.stringify(input)).toString("base64url");
|
|
1144
|
+
}
|
|
1145
|
+
function parseCursor(cursor, currentHead) {
|
|
1146
|
+
if (!cursor) return { headVersion: currentHead, offset: 0 };
|
|
1147
|
+
try {
|
|
1148
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1149
|
+
if (!isRecord(parsed) || parsed.headVersion !== null && !isVersionId(parsed.headVersion) || typeof parsed.offset !== "number" || !Number.isSafeInteger(parsed.offset) || parsed.offset < 0) {
|
|
1150
|
+
throw new Error("invalid");
|
|
1151
|
+
}
|
|
1152
|
+
return { headVersion: parsed.headVersion, offset: parsed.offset };
|
|
1153
|
+
} catch {
|
|
1154
|
+
throw new UserError2("Version cursor is invalid.", "invalid_version");
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
function localAuthorName() {
|
|
1158
|
+
return process.env.OPENAGENTPACK_VERSION_AUTHOR?.trim() || process.env.USER?.trim() || process.env.USERNAME?.trim() || "local";
|
|
1159
|
+
}
|
|
1160
|
+
function isRecord(value) {
|
|
1161
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1162
|
+
}
|
|
1163
|
+
function isFileSystemError(error, code) {
|
|
1164
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
1165
|
+
}
|
|
1166
|
+
function errorMessage(error) {
|
|
1167
|
+
return error instanceof Error ? error.message : String(error);
|
|
1168
|
+
}
|
|
1169
|
+
function postApplyVersionError(reason) {
|
|
1170
|
+
return new UserError2(
|
|
1171
|
+
`Remote Apply completed, but agents.yaml could not be versioned: ${reason}. Fix the local version store and rerun Apply; a no-op Apply will retry the version.`,
|
|
1172
|
+
"storage_failed"
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export {
|
|
1177
|
+
DirectoryProjectMutationConflictError,
|
|
1178
|
+
createDirectoryProjectVersionService,
|
|
1179
|
+
ProjectVersionError,
|
|
1180
|
+
readProjectVersionSource,
|
|
1181
|
+
getProjectVersionStatus,
|
|
1182
|
+
enableProjectVersioning,
|
|
1183
|
+
disableProjectVersioning,
|
|
1184
|
+
listProjectVersions,
|
|
1185
|
+
previewProjectVersion,
|
|
1186
|
+
restoreProjectVersion,
|
|
1187
|
+
prepareProjectVersion,
|
|
1188
|
+
commitPreparedProjectVersion,
|
|
1189
|
+
releasePreparedProjectVersion,
|
|
1190
|
+
createProjectVersionService
|
|
1191
|
+
};
|