@openprd/cli 0.1.19 → 0.1.22
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/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
- package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
- package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
- package/.openprd/design/active/asset-spec.md +19 -14
- package/.openprd/design/active/direction-plan.md +19 -3
- package/.openprd/design/active/facts-sheet.md +16 -7
- package/.openprd/design/active/image-preflight.md +6 -5
- package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
- package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
- package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
- package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
- package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
- package/.openprd/design/active/selected-direction.md +23 -9
- package/.openprd/engagements/active/control-plane-architecture.json +203 -0
- package/.openprd/engagements/active/control-plane-intake.json +418 -0
- package/.openprd/engagements/active/prd.md +183 -119
- package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
- package/.openprd/i18n-config.json +12 -0
- package/.openprd/ledger/events.jsonl +6 -0
- package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
- package/.openprd/manifest.json +21 -0
- package/AGENTS.md +7 -6
- package/README.md +31 -29
- package/README_EN.md +36 -39
- package/package.json +1 -1
- package/skills/openprd-frontend-design/SKILL.md +16 -0
- package/skills/openprd-harness/SKILL.md +7 -7
- package/skills/openprd-quality/SKILL.md +1 -1
- package/skills/openprd-requirement-intake/SKILL.md +1 -1
- package/skills/openprd-shared/SKILL.md +6 -6
- package/src/adapters/adapter-spi.js +193 -0
- package/src/adapters/capability-envelope.js +98 -0
- package/src/adapters/event-normalizer.js +55 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/install-safety.js +249 -0
- package/src/agent-canonical-content.js +4 -2
- package/src/agent-integration.js +169 -46
- package/src/cli/args.js +63 -4
- package/src/cli/gate-print.js +17 -0
- package/src/cli/quality-commands.js +18 -0
- package/src/cli/quality-print.js +10 -0
- package/src/cli/runtime-print.js +24 -0
- package/src/codex-hook-runner-template.mjs +129 -199
- package/src/codex-runtime.js +48 -5
- package/src/context/cache.js +245 -0
- package/src/context/compiler.js +438 -0
- package/src/context/constants.js +30 -0
- package/src/context/index.js +39 -0
- package/src/context/redaction.js +84 -0
- package/src/context/stable.js +69 -0
- package/src/context/telemetry.js +42 -0
- package/src/dev-standards.js +57 -0
- package/src/fleet.js +112 -95
- package/src/gates/index.js +2 -0
- package/src/gates/scoped-gates.js +256 -0
- package/src/gates/store.js +126 -0
- package/src/gates/workspace.js +41 -0
- package/src/html-artifacts.js +725 -28
- package/src/kernel/atomic-store.js +299 -0
- package/src/kernel/event-envelope.js +166 -0
- package/src/kernel/index.js +4 -0
- package/src/kernel/project-ledger.js +467 -0
- package/src/kernel/project-manifest.js +205 -0
- package/src/knowledge-v3/index.js +1 -0
- package/src/knowledge-v3/lifecycle.js +290 -0
- package/src/knowledge.js +14 -7
- package/src/openprd.js +71 -2
- package/src/review-model.js +413 -0
- package/src/review-presentation.js +1 -1
- package/src/run-harness.js +432 -38
- package/src/runtime/cli_runtime_README.md +28 -0
- package/src/runtime/errors.js +66 -0
- package/src/runtime/index.js +44 -0
- package/src/runtime/lane-schema.js +141 -0
- package/src/runtime/lane-store.js +279 -0
- package/src/runtime/task-runtime.js +449 -0
- package/src/runtime/workspace.js +179 -0
- package/src/runtime/write-set.js +206 -0
- package/src/session-binding.js +16 -3
- package/src/session-registry.js +59 -1
- package/src/upgrade/fleet-mutation.js +166 -0
- package/src/upgrade/fleet-transaction.js +398 -0
- package/src/upgrade/transaction-store.js +416 -0
- package/src/visual-compare-core.js +66 -27
- package/src/visual-compare.js +18 -12
- package/src/workspace-core.js +109 -7
- package/src/workspace-registry.js +39 -1
- package/src/workspace-workflow.js +18 -15
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
appendJsonlAtomic,
|
|
6
|
+
atomicWriteJson,
|
|
7
|
+
readJsonIfExists,
|
|
8
|
+
readJsonlRecovered,
|
|
9
|
+
withFileLock,
|
|
10
|
+
} from './atomic-store.js';
|
|
11
|
+
import {
|
|
12
|
+
assertEventEnvelope,
|
|
13
|
+
canonicalJson,
|
|
14
|
+
createEventEnvelope,
|
|
15
|
+
validateEventEnvelope,
|
|
16
|
+
} from './event-envelope.js';
|
|
17
|
+
import { ensureProjectManifest, readProjectManifest } from './project-manifest.js';
|
|
18
|
+
|
|
19
|
+
const LEDGER_SCHEMA = 'openprd.project-ledger';
|
|
20
|
+
const LEDGER_VERSION = 1;
|
|
21
|
+
const STREAM_HEAD_SCHEMA = 'openprd.stream-head';
|
|
22
|
+
const PROJECTION_SCHEMA = 'openprd.projection';
|
|
23
|
+
|
|
24
|
+
class RevisionConflictError extends Error {
|
|
25
|
+
constructor(streamId, expectedRevision, actualRevision) {
|
|
26
|
+
super(`Revision conflict for ${streamId}: expected ${expectedRevision}, found ${actualRevision}`);
|
|
27
|
+
this.name = 'RevisionConflictError';
|
|
28
|
+
this.code = 'OPENPRD_LEDGER_REVISION_CONFLICT';
|
|
29
|
+
this.streamId = streamId;
|
|
30
|
+
this.expectedRevision = expectedRevision;
|
|
31
|
+
this.actualRevision = actualRevision;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class IdempotencyConflictError extends Error {
|
|
36
|
+
constructor(idempotencyKey, existingEventId) {
|
|
37
|
+
super(`Idempotency key ${idempotencyKey} was already used by ${existingEventId} with different content`);
|
|
38
|
+
this.name = 'IdempotencyConflictError';
|
|
39
|
+
this.code = 'OPENPRD_LEDGER_IDEMPOTENCY_CONFLICT';
|
|
40
|
+
this.idempotencyKey = idempotencyKey;
|
|
41
|
+
this.existingEventId = existingEventId;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hashName(value) {
|
|
46
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 24);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateLogicalId(value, label) {
|
|
50
|
+
if (
|
|
51
|
+
typeof value !== 'string'
|
|
52
|
+
|| !value.trim()
|
|
53
|
+
|| value.includes('..')
|
|
54
|
+
|| /[\0\r\n]/.test(value)
|
|
55
|
+
) {
|
|
56
|
+
throw new TypeError(`${label} must be a safe non-empty logical ID`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeProjectionName(name) {
|
|
62
|
+
validateLogicalId(name, 'projection name');
|
|
63
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'projection';
|
|
64
|
+
return `${slug.slice(0, 48)}-${hashName(name).slice(0, 8)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ledgerPaths(projectRoot) {
|
|
68
|
+
const root = path.join(path.resolve(projectRoot), '.openprd', 'ledger');
|
|
69
|
+
return {
|
|
70
|
+
root,
|
|
71
|
+
events: path.join(root, 'events.jsonl'),
|
|
72
|
+
heads: path.join(root, 'heads'),
|
|
73
|
+
projections: path.join(root, 'projections'),
|
|
74
|
+
lock: path.join(root, 'locks', 'ledger.lock'),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function streamPaths(projectRoot, streamId) {
|
|
79
|
+
validateLogicalId(streamId, 'streamId');
|
|
80
|
+
const paths = ledgerPaths(projectRoot);
|
|
81
|
+
const key = hashName(streamId);
|
|
82
|
+
return {
|
|
83
|
+
...paths,
|
|
84
|
+
stream: paths.events,
|
|
85
|
+
head: path.join(paths.heads, `${key}.json`),
|
|
86
|
+
key,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function projectionPath(projectRoot, name) {
|
|
91
|
+
return path.join(ledgerPaths(projectRoot).projections, `${normalizeProjectionName(name)}.json`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eventInputFingerprint(streamId, input) {
|
|
95
|
+
const metadata = { ...(input?.metadata ?? {}) };
|
|
96
|
+
delete metadata.idempotencyKey;
|
|
97
|
+
delete metadata.idempotencyFingerprint;
|
|
98
|
+
delete metadata.ledgerPosition;
|
|
99
|
+
return createHash('sha256').update(canonicalJson({
|
|
100
|
+
streamId,
|
|
101
|
+
eventType: input?.eventType,
|
|
102
|
+
actor: input?.actor ?? { type: 'system' },
|
|
103
|
+
causationId: input?.causationId ?? null,
|
|
104
|
+
correlationId: input?.correlationId ?? null,
|
|
105
|
+
payload: input?.payload ?? {},
|
|
106
|
+
metadata,
|
|
107
|
+
})).digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function deriveHeads(events) {
|
|
111
|
+
const heads = new Map();
|
|
112
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
113
|
+
const event = events[index];
|
|
114
|
+
heads.set(event.streamId, {
|
|
115
|
+
schema: STREAM_HEAD_SCHEMA,
|
|
116
|
+
version: LEDGER_VERSION,
|
|
117
|
+
projectId: event.projectId,
|
|
118
|
+
streamId: event.streamId,
|
|
119
|
+
revision: event.revision,
|
|
120
|
+
lastEventId: event.eventId,
|
|
121
|
+
ledgerPosition: event.metadata?.ledgerPosition ?? index + 1,
|
|
122
|
+
updatedAt: event.occurredAt,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return heads;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emptyHead(streamId) {
|
|
129
|
+
return {
|
|
130
|
+
schema: STREAM_HEAD_SCHEMA,
|
|
131
|
+
version: LEDGER_VERSION,
|
|
132
|
+
projectId: null,
|
|
133
|
+
streamId,
|
|
134
|
+
revision: 0,
|
|
135
|
+
lastEventId: null,
|
|
136
|
+
ledgerPosition: 0,
|
|
137
|
+
updatedAt: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sameHead(left, right) {
|
|
142
|
+
return left?.schema === right?.schema
|
|
143
|
+
&& left?.version === right?.version
|
|
144
|
+
&& left?.projectId === right?.projectId
|
|
145
|
+
&& left?.streamId === right?.streamId
|
|
146
|
+
&& left?.revision === right?.revision
|
|
147
|
+
&& left?.lastEventId === right?.lastEventId
|
|
148
|
+
&& left?.ledgerPosition === right?.ledgerPosition
|
|
149
|
+
&& left?.updatedAt === right?.updatedAt;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function openProjectLedger(projectRoot, options = {}) {
|
|
153
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
154
|
+
const paths = ledgerPaths(resolvedRoot);
|
|
155
|
+
const clock = options.clock ?? (() => new Date());
|
|
156
|
+
|
|
157
|
+
const ensureManifest = () => ensureProjectManifest(resolvedRoot, {
|
|
158
|
+
clock,
|
|
159
|
+
idFactory: options.projectIdFactory,
|
|
160
|
+
readerVersion: options.readerVersion,
|
|
161
|
+
writerVersion: options.writerVersion,
|
|
162
|
+
formats: options.formats,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const readManifest = () => readProjectManifest(resolvedRoot, {
|
|
166
|
+
mode: 'read',
|
|
167
|
+
readerVersion: options.readerVersion,
|
|
168
|
+
writerVersion: options.writerVersion,
|
|
169
|
+
formats: options.formats,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
async function readRaw(optionsForRead = {}) {
|
|
173
|
+
return withFileLock(`${paths.events}.lock`, () => readJsonlRecovered(paths.events, {
|
|
174
|
+
repair: optionsForRead.repairTail === true,
|
|
175
|
+
}), { ...options, clock });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readAll(optionsForRead = {}) {
|
|
179
|
+
await readManifest();
|
|
180
|
+
const { records } = await readRaw(optionsForRead);
|
|
181
|
+
for (const event of records) assertEventEnvelope(event);
|
|
182
|
+
return records.filter((event) => {
|
|
183
|
+
if (optionsForRead.fromPosition && event.metadata?.ledgerPosition < optionsForRead.fromPosition) return false;
|
|
184
|
+
if (optionsForRead.toPosition && event.metadata?.ledgerPosition > optionsForRead.toPosition) return false;
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readStream(streamId, optionsForRead = {}) {
|
|
190
|
+
validateLogicalId(streamId, 'streamId');
|
|
191
|
+
const events = (await readAll(optionsForRead)).filter((event) => event.streamId === streamId);
|
|
192
|
+
for (const event of events) assertEventEnvelope(event);
|
|
193
|
+
const fromRevision = optionsForRead.fromRevision ?? 1;
|
|
194
|
+
const toRevision = optionsForRead.toRevision ?? Number.POSITIVE_INFINITY;
|
|
195
|
+
return events.filter((event) => event.revision >= fromRevision && event.revision <= toRevision);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function head(streamId, headOptions = {}) {
|
|
199
|
+
await readManifest();
|
|
200
|
+
const streamPath = streamPaths(resolvedRoot, streamId);
|
|
201
|
+
if (!headOptions.authoritative) {
|
|
202
|
+
const persisted = await readJsonIfExists(streamPath.head, null);
|
|
203
|
+
if (persisted !== null) return persisted;
|
|
204
|
+
}
|
|
205
|
+
const events = await readStream(streamId);
|
|
206
|
+
return deriveHeads(events).get(streamId) ?? emptyHead(streamId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function append(streamId, input, appendOptions = {}) {
|
|
210
|
+
validateLogicalId(streamId, 'streamId');
|
|
211
|
+
if (
|
|
212
|
+
appendOptions.expectedRevision !== undefined
|
|
213
|
+
&& (!Number.isSafeInteger(appendOptions.expectedRevision) || appendOptions.expectedRevision < 0)
|
|
214
|
+
) {
|
|
215
|
+
throw new TypeError('expectedRevision must be a non-negative safe integer');
|
|
216
|
+
}
|
|
217
|
+
const idempotencyKey = appendOptions.idempotencyKey === undefined
|
|
218
|
+
? null
|
|
219
|
+
: validateLogicalId(appendOptions.idempotencyKey, 'idempotencyKey');
|
|
220
|
+
const fingerprint = eventInputFingerprint(streamId, input);
|
|
221
|
+
|
|
222
|
+
return withFileLock(paths.lock, async () => {
|
|
223
|
+
const manifest = await ensureManifest();
|
|
224
|
+
const { records: events } = await readRaw({ repairTail: true });
|
|
225
|
+
if (idempotencyKey) {
|
|
226
|
+
const existing = events.find((event) => event.metadata?.idempotencyKey === idempotencyKey);
|
|
227
|
+
if (existing) {
|
|
228
|
+
if (existing.metadata?.idempotencyFingerprint !== fingerprint) {
|
|
229
|
+
throw new IdempotencyConflictError(idempotencyKey, existing.eventId);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
event: existing,
|
|
233
|
+
head: deriveHeads(events).get(existing.streamId),
|
|
234
|
+
idempotent: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const currentHead = deriveHeads(events).get(streamId) ?? emptyHead(streamId);
|
|
240
|
+
if (
|
|
241
|
+
appendOptions.expectedRevision !== undefined
|
|
242
|
+
&& appendOptions.expectedRevision !== currentHead.revision
|
|
243
|
+
) {
|
|
244
|
+
throw new RevisionConflictError(streamId, appendOptions.expectedRevision, currentHead.revision);
|
|
245
|
+
}
|
|
246
|
+
const metadata = {
|
|
247
|
+
...(input?.metadata ?? {}),
|
|
248
|
+
ledgerPosition: events.length + 1,
|
|
249
|
+
};
|
|
250
|
+
if (idempotencyKey) {
|
|
251
|
+
metadata.idempotencyKey = idempotencyKey;
|
|
252
|
+
metadata.idempotencyFingerprint = fingerprint;
|
|
253
|
+
}
|
|
254
|
+
const event = createEventEnvelope({
|
|
255
|
+
...input,
|
|
256
|
+
projectId: manifest.projectId,
|
|
257
|
+
streamId,
|
|
258
|
+
revision: currentHead.revision + 1,
|
|
259
|
+
metadata,
|
|
260
|
+
}, {
|
|
261
|
+
clock,
|
|
262
|
+
idFactory: options.eventIdFactory,
|
|
263
|
+
});
|
|
264
|
+
await appendJsonlAtomic(paths.events, event, {
|
|
265
|
+
...appendOptions,
|
|
266
|
+
lockPath: `${paths.events}.lock`,
|
|
267
|
+
clock,
|
|
268
|
+
});
|
|
269
|
+
const nextHead = deriveHeads([...events, event]).get(streamId);
|
|
270
|
+
await atomicWriteJson(streamPaths(resolvedRoot, streamId).head, nextHead);
|
|
271
|
+
return { event, head: nextHead, idempotent: false };
|
|
272
|
+
}, { ...options, ...appendOptions, clock });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function verifyUnlocked(verifyOptions = {}) {
|
|
276
|
+
const errors = [];
|
|
277
|
+
const warnings = [];
|
|
278
|
+
let events;
|
|
279
|
+
try {
|
|
280
|
+
({ records: events } = await readRaw({ repairTail: verifyOptions.repairTail === true }));
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
errors: [{ code: error.code ?? 'LEDGER_READ_FAILED', message: error.message }],
|
|
285
|
+
warnings,
|
|
286
|
+
eventCount: 0,
|
|
287
|
+
streams: 0,
|
|
288
|
+
heads: {},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
let manifest = null;
|
|
292
|
+
try {
|
|
293
|
+
manifest = await readManifest();
|
|
294
|
+
} catch (error) {
|
|
295
|
+
errors.push({ code: error.code ?? 'MANIFEST_READ_FAILED', message: error.message });
|
|
296
|
+
}
|
|
297
|
+
const revisions = new Map();
|
|
298
|
+
const eventIds = new Set();
|
|
299
|
+
const idempotencyKeys = new Map();
|
|
300
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
301
|
+
const event = events[index];
|
|
302
|
+
const validation = validateEventEnvelope(event);
|
|
303
|
+
if (!validation.ok) {
|
|
304
|
+
errors.push({
|
|
305
|
+
code: 'INVALID_EVENT',
|
|
306
|
+
position: index + 1,
|
|
307
|
+
eventId: event?.eventId ?? null,
|
|
308
|
+
details: validation.errors,
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (manifest && event.projectId !== manifest.projectId) {
|
|
313
|
+
errors.push({
|
|
314
|
+
code: 'PROJECT_ID_MISMATCH',
|
|
315
|
+
eventId: event.eventId,
|
|
316
|
+
expectedProjectId: manifest.projectId,
|
|
317
|
+
actualProjectId: event.projectId,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const expectedRevision = (revisions.get(event.streamId) ?? 0) + 1;
|
|
321
|
+
if (event.revision !== expectedRevision) {
|
|
322
|
+
errors.push({
|
|
323
|
+
code: 'REVISION_GAP',
|
|
324
|
+
streamId: event.streamId,
|
|
325
|
+
expectedRevision,
|
|
326
|
+
actualRevision: event.revision,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
revisions.set(event.streamId, event.revision);
|
|
330
|
+
if (event.metadata?.ledgerPosition !== index + 1) {
|
|
331
|
+
errors.push({
|
|
332
|
+
code: 'POSITION_MISMATCH',
|
|
333
|
+
eventId: event.eventId,
|
|
334
|
+
expectedPosition: index + 1,
|
|
335
|
+
actualPosition: event.metadata?.ledgerPosition ?? null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (eventIds.has(event.eventId)) errors.push({ code: 'DUPLICATE_EVENT_ID', eventId: event.eventId });
|
|
339
|
+
eventIds.add(event.eventId);
|
|
340
|
+
const idempotencyKey = event.metadata?.idempotencyKey;
|
|
341
|
+
if (idempotencyKey) {
|
|
342
|
+
if (idempotencyKeys.has(idempotencyKey)) {
|
|
343
|
+
errors.push({
|
|
344
|
+
code: 'DUPLICATE_IDEMPOTENCY_KEY',
|
|
345
|
+
idempotencyKey,
|
|
346
|
+
eventIds: [idempotencyKeys.get(idempotencyKey), event.eventId],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
idempotencyKeys.set(idempotencyKey, event.eventId);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const expectedHeads = deriveHeads(events.filter((event) => validateEventEnvelope(event).ok));
|
|
354
|
+
for (const [streamId, expectedHead] of expectedHeads) {
|
|
355
|
+
const headPath = streamPaths(resolvedRoot, streamId).head;
|
|
356
|
+
const actualHead = await readJsonIfExists(headPath, null).catch((error) => {
|
|
357
|
+
errors.push({ code: 'HEAD_READ_FAILED', streamId, message: error.message });
|
|
358
|
+
return null;
|
|
359
|
+
});
|
|
360
|
+
if (!sameHead(actualHead, expectedHead)) {
|
|
361
|
+
if (verifyOptions.repairHeads) {
|
|
362
|
+
await atomicWriteJson(headPath, expectedHead);
|
|
363
|
+
warnings.push({ code: 'HEAD_REBUILT', streamId });
|
|
364
|
+
} else {
|
|
365
|
+
errors.push({ code: 'HEAD_MISMATCH', streamId, expected: expectedHead, actual: actualHead });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
ok: errors.length === 0,
|
|
371
|
+
errors,
|
|
372
|
+
warnings,
|
|
373
|
+
eventCount: events.length,
|
|
374
|
+
streams: expectedHeads.size,
|
|
375
|
+
heads: Object.fromEntries(Array.from(expectedHeads, ([streamId, value]) => [streamId, value.revision])),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function verify(verifyOptions = {}) {
|
|
380
|
+
return withFileLock(paths.lock, () => verifyUnlocked(verifyOptions), {
|
|
381
|
+
...options,
|
|
382
|
+
...verifyOptions,
|
|
383
|
+
clock,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function rebuildProjection(name, reducer, initialState, projectionOptions = {}) {
|
|
388
|
+
if (typeof reducer !== 'function') throw new TypeError('projection reducer must be a function');
|
|
389
|
+
const events = await readAll();
|
|
390
|
+
let state = structuredClone(initialState);
|
|
391
|
+
let eventsApplied = 0;
|
|
392
|
+
const selectedStreams = projectionOptions.streams ? new Set(projectionOptions.streams) : null;
|
|
393
|
+
for (const event of events) {
|
|
394
|
+
if (selectedStreams && !selectedStreams.has(event.streamId)) continue;
|
|
395
|
+
const reduced = await reducer(state, event);
|
|
396
|
+
if (reduced !== undefined) state = reduced;
|
|
397
|
+
eventsApplied += 1;
|
|
398
|
+
}
|
|
399
|
+
const selectedEvents = selectedStreams
|
|
400
|
+
? events.filter((event) => selectedStreams.has(event.streamId))
|
|
401
|
+
: events;
|
|
402
|
+
const streamHeads = Object.fromEntries(
|
|
403
|
+
Array.from(deriveHeads(selectedEvents), ([streamId, value]) => [streamId, value.revision])
|
|
404
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
405
|
+
);
|
|
406
|
+
const timestampValue = clock();
|
|
407
|
+
const rebuiltAt = (timestampValue instanceof Date ? timestampValue : new Date(timestampValue)).toISOString();
|
|
408
|
+
const projection = {
|
|
409
|
+
schema: PROJECTION_SCHEMA,
|
|
410
|
+
version: LEDGER_VERSION,
|
|
411
|
+
name,
|
|
412
|
+
rebuiltAt,
|
|
413
|
+
eventsApplied,
|
|
414
|
+
lastLedgerPosition: selectedEvents.at(-1)?.metadata?.ledgerPosition ?? 0,
|
|
415
|
+
streamHeads,
|
|
416
|
+
state,
|
|
417
|
+
};
|
|
418
|
+
if (projectionOptions.persist !== false) await atomicWriteJson(projectionPath(resolvedRoot, name), projection);
|
|
419
|
+
return projection;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function readProjection(name) {
|
|
423
|
+
await readManifest();
|
|
424
|
+
const filePath = projectionPath(resolvedRoot, name);
|
|
425
|
+
const projection = await readJsonIfExists(filePath, null);
|
|
426
|
+
if (projection === null) {
|
|
427
|
+
const error = new Error(`Projection ${name} does not exist at ${filePath}`);
|
|
428
|
+
error.code = 'OPENPRD_PROJECTION_NOT_FOUND';
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
return projection;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function listStreams() {
|
|
435
|
+
return Array.from(deriveHeads(await readAll()).keys()).sort();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
schema: LEDGER_SCHEMA,
|
|
440
|
+
version: LEDGER_VERSION,
|
|
441
|
+
projectRoot: resolvedRoot,
|
|
442
|
+
paths,
|
|
443
|
+
append,
|
|
444
|
+
head,
|
|
445
|
+
listStreams,
|
|
446
|
+
pathsForStream: (streamId) => streamPaths(resolvedRoot, streamId),
|
|
447
|
+
projectionPath: (name) => projectionPath(resolvedRoot, name),
|
|
448
|
+
readAll,
|
|
449
|
+
readProjection,
|
|
450
|
+
readStream,
|
|
451
|
+
rebuildProjection,
|
|
452
|
+
verify,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export {
|
|
457
|
+
IdempotencyConflictError,
|
|
458
|
+
LEDGER_SCHEMA,
|
|
459
|
+
LEDGER_VERSION,
|
|
460
|
+
PROJECTION_SCHEMA,
|
|
461
|
+
RevisionConflictError,
|
|
462
|
+
STREAM_HEAD_SCHEMA,
|
|
463
|
+
ledgerPaths,
|
|
464
|
+
openProjectLedger,
|
|
465
|
+
projectionPath,
|
|
466
|
+
streamPaths,
|
|
467
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
atomicWriteJson,
|
|
6
|
+
readJsonIfExists,
|
|
7
|
+
withFileLock,
|
|
8
|
+
} from './atomic-store.js';
|
|
9
|
+
|
|
10
|
+
const PROJECT_MANIFEST_SCHEMA = 'openprd.project-manifest';
|
|
11
|
+
const PROJECT_MANIFEST_VERSION = 1;
|
|
12
|
+
const CURRENT_READER_VERSION = 1;
|
|
13
|
+
const CURRENT_WRITER_VERSION = 1;
|
|
14
|
+
const CURRENT_COMPATIBILITY = Object.freeze({
|
|
15
|
+
minimumReaderVersion: 1,
|
|
16
|
+
minimumWriterVersion: 1,
|
|
17
|
+
});
|
|
18
|
+
const REQUIRED_FORMATS = Object.freeze(['eventEnvelope', 'ledger', 'projection']);
|
|
19
|
+
const CURRENT_FORMATS = Object.freeze({
|
|
20
|
+
eventEnvelope: 1,
|
|
21
|
+
ledger: 1,
|
|
22
|
+
projection: 1,
|
|
23
|
+
contextCapsule: 1,
|
|
24
|
+
taskRuntime: 1,
|
|
25
|
+
scopedGate: 1,
|
|
26
|
+
reviewModel: 1,
|
|
27
|
+
knowledge: 3,
|
|
28
|
+
adapter: 1,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
class ProjectManifestValidationError extends Error {
|
|
32
|
+
constructor(errors) {
|
|
33
|
+
super(`Invalid OpenPrd project manifest: ${errors.map((item) => `${item.path} ${item.message}`).join('; ')}`);
|
|
34
|
+
this.name = 'ProjectManifestValidationError';
|
|
35
|
+
this.code = 'OPENPRD_INVALID_PROJECT_MANIFEST';
|
|
36
|
+
this.errors = errors;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class ManifestCompatibilityError extends Error {
|
|
41
|
+
constructor(code, message, details = {}) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'ManifestCompatibilityError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
Object.assign(this, details);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
51
|
+
const prototype = Object.getPrototypeOf(value);
|
|
52
|
+
return prototype === Object.prototype || prototype === null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function positiveVersion(value) {
|
|
56
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function projectManifestPath(projectRoot) {
|
|
60
|
+
return path.join(path.resolve(projectRoot), '.openprd', 'manifest.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateProjectManifest(manifest) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
const add = (pathName, message) => errors.push({ path: pathName, message });
|
|
66
|
+
if (!isPlainObject(manifest)) {
|
|
67
|
+
add('$', 'must be a plain object');
|
|
68
|
+
return { ok: false, errors };
|
|
69
|
+
}
|
|
70
|
+
if (manifest.schema !== PROJECT_MANIFEST_SCHEMA) add('schema', `must equal ${PROJECT_MANIFEST_SCHEMA}`);
|
|
71
|
+
if (!positiveVersion(manifest.schemaVersion)) add('schemaVersion', 'must be a positive integer');
|
|
72
|
+
if (typeof manifest.projectId !== 'string' || !manifest.projectId.trim()) add('projectId', 'must be a non-empty string');
|
|
73
|
+
if (typeof manifest.createdAt !== 'string' || Number.isNaN(Date.parse(manifest.createdAt))) {
|
|
74
|
+
add('createdAt', 'must be an ISO-compatible timestamp');
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(manifest.compatibility)) {
|
|
77
|
+
add('compatibility', 'must be a plain object');
|
|
78
|
+
}
|
|
79
|
+
for (const field of ['minimumReaderVersion', 'minimumWriterVersion']) {
|
|
80
|
+
if (!positiveVersion(manifest.compatibility?.[field])) add(`compatibility.${field}`, 'must be a positive integer');
|
|
81
|
+
}
|
|
82
|
+
if (!isPlainObject(manifest.formats)) add('formats', 'must be a plain object');
|
|
83
|
+
for (const field of REQUIRED_FORMATS) {
|
|
84
|
+
if (!positiveVersion(manifest.formats?.[field])) add(`formats.${field}`, 'must be a positive integer');
|
|
85
|
+
}
|
|
86
|
+
return { ok: errors.length === 0, errors };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertValidProjectManifest(manifest) {
|
|
90
|
+
const validation = validateProjectManifest(manifest);
|
|
91
|
+
if (!validation.ok) throw new ProjectManifestValidationError(validation.errors);
|
|
92
|
+
return manifest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertProjectManifestCompatible(manifest, options = {}) {
|
|
96
|
+
assertValidProjectManifest(manifest);
|
|
97
|
+
const mode = options.mode ?? 'read';
|
|
98
|
+
const readerVersion = options.readerVersion ?? CURRENT_READER_VERSION;
|
|
99
|
+
const writerVersion = options.writerVersion ?? CURRENT_WRITER_VERSION;
|
|
100
|
+
const supportedManifestVersion = options.manifestVersion ?? PROJECT_MANIFEST_VERSION;
|
|
101
|
+
const supportedFormats = options.formats ?? CURRENT_FORMATS;
|
|
102
|
+
|
|
103
|
+
if (manifest.schemaVersion > supportedManifestVersion) {
|
|
104
|
+
throw new ManifestCompatibilityError(
|
|
105
|
+
'OPENPRD_MANIFEST_TOO_NEW',
|
|
106
|
+
`Manifest schema ${manifest.schemaVersion} requires a newer OpenPrd reader`,
|
|
107
|
+
{ requiredVersion: manifest.schemaVersion, supportedVersion: supportedManifestVersion },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (manifest.compatibility.minimumReaderVersion > readerVersion) {
|
|
111
|
+
throw new ManifestCompatibilityError(
|
|
112
|
+
'OPENPRD_READER_TOO_OLD',
|
|
113
|
+
`Project requires reader ${manifest.compatibility.minimumReaderVersion}, current reader is ${readerVersion}`,
|
|
114
|
+
{ requiredVersion: manifest.compatibility.minimumReaderVersion, supportedVersion: readerVersion },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (mode === 'write' && manifest.compatibility.minimumWriterVersion > writerVersion) {
|
|
118
|
+
throw new ManifestCompatibilityError(
|
|
119
|
+
'OPENPRD_WRITER_TOO_OLD',
|
|
120
|
+
`Project requires writer ${manifest.compatibility.minimumWriterVersion}, current writer is ${writerVersion}`,
|
|
121
|
+
{ requiredVersion: manifest.compatibility.minimumWriterVersion, supportedVersion: writerVersion },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
for (const [format, requiredVersion] of Object.entries(manifest.formats)) {
|
|
125
|
+
const supportedVersion = supportedFormats[format];
|
|
126
|
+
if (!positiveVersion(supportedVersion) || requiredVersion > supportedVersion) {
|
|
127
|
+
throw new ManifestCompatibilityError(
|
|
128
|
+
'OPENPRD_FORMAT_TOO_NEW',
|
|
129
|
+
`Project format ${format}@${requiredVersion} is newer than supported ${supportedVersion ?? 0}`,
|
|
130
|
+
{ format, requiredVersion, supportedVersion: supportedVersion ?? 0 },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return manifest;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeCreatedAt(clock) {
|
|
138
|
+
const value = clock();
|
|
139
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
140
|
+
if (Number.isNaN(date.getTime())) throw new TypeError('Project manifest clock must return a valid date');
|
|
141
|
+
return date.toISOString();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createProjectManifest(options = {}) {
|
|
145
|
+
const clock = options.clock ?? (() => new Date());
|
|
146
|
+
const idFactory = options.idFactory ?? (() => `project_${randomUUID()}`);
|
|
147
|
+
const manifest = {
|
|
148
|
+
schema: PROJECT_MANIFEST_SCHEMA,
|
|
149
|
+
schemaVersion: PROJECT_MANIFEST_VERSION,
|
|
150
|
+
projectId: options.projectId ?? idFactory(),
|
|
151
|
+
createdAt: normalizeCreatedAt(clock),
|
|
152
|
+
compatibility: {
|
|
153
|
+
...CURRENT_COMPATIBILITY,
|
|
154
|
+
...(options.compatibility ?? {}),
|
|
155
|
+
},
|
|
156
|
+
formats: {
|
|
157
|
+
...CURRENT_FORMATS,
|
|
158
|
+
...(options.formats ?? {}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return assertValidProjectManifest(manifest);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readProjectManifest(projectRoot, options = {}) {
|
|
165
|
+
const manifestPath = projectManifestPath(projectRoot);
|
|
166
|
+
const manifest = await readJsonIfExists(manifestPath, null);
|
|
167
|
+
if (manifest === null) {
|
|
168
|
+
const error = new Error(`OpenPrd project manifest does not exist at ${manifestPath}`);
|
|
169
|
+
error.code = 'OPENPRD_MANIFEST_NOT_FOUND';
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
return assertProjectManifestCompatible(manifest, options);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function ensureProjectManifest(projectRoot, options = {}) {
|
|
176
|
+
const manifestPath = projectManifestPath(projectRoot);
|
|
177
|
+
const lockPath = `${manifestPath}.lock`;
|
|
178
|
+
return withFileLock(lockPath, async () => {
|
|
179
|
+
const existing = await readJsonIfExists(manifestPath, null);
|
|
180
|
+
if (existing !== null) return assertProjectManifestCompatible(existing, { ...options, mode: 'write' });
|
|
181
|
+
const manifest = createProjectManifest(options);
|
|
182
|
+
assertProjectManifestCompatible(manifest, { ...options, mode: 'write' });
|
|
183
|
+
await atomicWriteJson(manifestPath, manifest);
|
|
184
|
+
return manifest;
|
|
185
|
+
}, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export {
|
|
189
|
+
CURRENT_COMPATIBILITY,
|
|
190
|
+
CURRENT_FORMATS,
|
|
191
|
+
CURRENT_READER_VERSION,
|
|
192
|
+
CURRENT_WRITER_VERSION,
|
|
193
|
+
REQUIRED_FORMATS,
|
|
194
|
+
ManifestCompatibilityError,
|
|
195
|
+
PROJECT_MANIFEST_SCHEMA,
|
|
196
|
+
PROJECT_MANIFEST_VERSION,
|
|
197
|
+
ProjectManifestValidationError,
|
|
198
|
+
assertProjectManifestCompatible,
|
|
199
|
+
assertValidProjectManifest,
|
|
200
|
+
createProjectManifest,
|
|
201
|
+
ensureProjectManifest,
|
|
202
|
+
projectManifestPath,
|
|
203
|
+
readProjectManifest,
|
|
204
|
+
validateProjectManifest,
|
|
205
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './lifecycle.js';
|