wendkeep 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.en.md +20 -4
- package/README.md +20 -4
- package/bin/wendkeep.mjs +2 -344
- package/hooks/locale.mjs +1 -73
- package/hooks/vault-runtime-store.mjs +1 -558
- package/package.json +2 -2
- package/packages/cli/package.json +2 -1
- package/packages/cli/src/index.mjs +349 -0
- package/packages/harness/src/flow-store.mjs +558 -0
- package/packages/harness/src/index.mjs +1 -0
- package/packages/vault/src/index.mjs +1 -0
- package/packages/vault/src/locale.mjs +74 -0
- package/packages/vault/src/vault-path-safety.mjs +321 -152
|
@@ -1,558 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// deliberately no dependency on change/sensor/profile policy modules.
|
|
3
|
-
import {
|
|
4
|
-
readFileSync, readdirSync,
|
|
5
|
-
} from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { getLocale } from './locale.mjs';
|
|
8
|
-
import {
|
|
9
|
-
assertVaultPathSafe, mkdirVaultPath, VAULT_LOCK_BUSY, withVaultPathLock,
|
|
10
|
-
writeVaultFileAtomic,
|
|
11
|
-
} from './vault-path-safety.mjs';
|
|
12
|
-
|
|
13
|
-
const ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
14
|
-
const WINDOWS_RESERVED_RE = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i;
|
|
15
|
-
const MISSING = Symbol('FLOW_STORE_MISSING');
|
|
16
|
-
|
|
17
|
-
function safeId(value, name) {
|
|
18
|
-
const id = String(value || '').trim();
|
|
19
|
-
if (!ID_RE.test(id)
|
|
20
|
-
|| id.startsWith('.')
|
|
21
|
-
|| id.endsWith('.')
|
|
22
|
-
|| WINDOWS_RESERVED_RE.test(id)) {
|
|
23
|
-
throw new TypeError(`${name} inválido: ${id || '(vazio)'}`);
|
|
24
|
-
}
|
|
25
|
-
return id;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function runtimeRoot(vaultBase) {
|
|
29
|
-
const root = join(vaultBase, '.brain', 'runtime', 'flows');
|
|
30
|
-
return assertVaultPathSafe(vaultBase, root, {
|
|
31
|
-
expectedType: 'directory', label: 'raiz runtime de FLOW',
|
|
32
|
-
}).target;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function sessionRoot(vaultBase, sessionId) {
|
|
36
|
-
const root = join(runtimeRoot(vaultBase), safeId(sessionId, 'session_id'));
|
|
37
|
-
return assertVaultPathSafe(vaultBase, root, {
|
|
38
|
-
expectedType: 'directory', label: 'raiz runtime da sessão FLOW',
|
|
39
|
-
}).target;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function flowDir(vaultBase, sessionId, flowId) {
|
|
43
|
-
const dir = join(sessionRoot(vaultBase, sessionId), safeId(flowId, 'flow_id'));
|
|
44
|
-
return assertVaultPathSafe(vaultBase, dir, {
|
|
45
|
-
expectedType: 'directory', label: 'raiz runtime do FLOW',
|
|
46
|
-
}).target;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function canonical(value) {
|
|
50
|
-
if (Array.isArray(value)) return value.map(canonical);
|
|
51
|
-
if (value && typeof value === 'object') {
|
|
52
|
-
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
53
|
-
}
|
|
54
|
-
return value;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function canonicalText(value) {
|
|
58
|
-
return `${JSON.stringify(canonical(value), null, 2)}\n`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function readJson(vaultBase, path, label) {
|
|
62
|
-
const checked = assertVaultPathSafe(vaultBase, path, { expectedType: 'file', label });
|
|
63
|
-
if (!checked.exists) return MISSING;
|
|
64
|
-
try {
|
|
65
|
-
return JSON.parse(readFileSync(checked.target, 'utf8'));
|
|
66
|
-
} catch (cause) {
|
|
67
|
-
const error = new Error(`${label} corrompido: ${path}`);
|
|
68
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
69
|
-
error.cause = cause;
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function immutableJson(vaultBase, path, value) {
|
|
75
|
-
const current = readJson(vaultBase, path, 'artefato FLOW');
|
|
76
|
-
const content = canonicalText(value);
|
|
77
|
-
if (current !== MISSING) {
|
|
78
|
-
if (canonicalText(current) === content) return { created: false, path };
|
|
79
|
-
const error = new Error(`artefato FLOW imutável já existe: ${path}`);
|
|
80
|
-
error.code = 'FLOW_IMMUTABLE_CONFLICT';
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
writeVaultFileAtomic(vaultBase, path, content, 'utf8', { label: 'artefato FLOW imutável' });
|
|
84
|
-
return { created: true, path };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function underSessionLock(vaultBase, sessionId, fn) {
|
|
88
|
-
const root = sessionRoot(vaultBase, sessionId);
|
|
89
|
-
mkdirVaultPath(vaultBase, root, { label: 'raiz runtime da sessão FLOW' });
|
|
90
|
-
const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, { timeoutMs: 5000 });
|
|
91
|
-
if (typeof outcome === 'symbol') {
|
|
92
|
-
const error = new Error(`store FLOW ocupado para sessão ${sessionId}`);
|
|
93
|
-
error.code = 'FLOW_STORE_BUSY';
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
return outcome;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Lock hierarchy for the durable promotion saga is always:
|
|
100
|
-
// change slug (this lock) -> FLOW session store / SESSION_REGISTRY. Session-note projection
|
|
101
|
-
// happens only after the terminal write and lock release. No store mutator acquires the slug
|
|
102
|
-
// lock, so a session lock is never held while waiting for it. Unlike the generic short write
|
|
103
|
-
// lock, a live owner is never reaped by age.
|
|
104
|
-
export function withFlowPromotionLock(vaultBase, changeSlug, fn, {
|
|
105
|
-
timeoutMs = 15_000,
|
|
106
|
-
ownerGraceMs = 1_000,
|
|
107
|
-
} = {}) {
|
|
108
|
-
const slug = safeId(changeSlug, 'change_slug');
|
|
109
|
-
const root = join(vaultBase, '.brain', 'runtime', 'flow-promotion-locks');
|
|
110
|
-
mkdirVaultPath(vaultBase, root, { label: 'raiz de locks de promoção FLOW' });
|
|
111
|
-
const outcome = withVaultPathLock(vaultBase, join(root, slug), fn, {
|
|
112
|
-
timeoutMs,
|
|
113
|
-
staleMs: ownerGraceMs,
|
|
114
|
-
});
|
|
115
|
-
if (outcome === VAULT_LOCK_BUSY) {
|
|
116
|
-
const busy = new Error(`promoção FLOW ocupada para a change ${slug}`);
|
|
117
|
-
busy.code = 'FLOW_PROMOTION_BUSY';
|
|
118
|
-
throw busy;
|
|
119
|
-
}
|
|
120
|
-
return outcome;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function isCanonicalFlowPath(value, { allowTree = false, allowProjectRoot = false } = {}) {
|
|
124
|
-
if (typeof value !== 'string' || !value || value !== value.trim()) return false;
|
|
125
|
-
if (allowProjectRoot && value === '.') return true;
|
|
126
|
-
if (value.includes('\\')
|
|
127
|
-
|| value.startsWith('/')
|
|
128
|
-
|| /^[A-Za-z]:/.test(value)
|
|
129
|
-
|| /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
130
|
-
const segments = value.split('/');
|
|
131
|
-
if (!segments.length || segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
const tree = segments.at(-1) === '**';
|
|
135
|
-
if (tree && (!allowTree || segments.length < 2)) return false;
|
|
136
|
-
const literalSegments = tree ? segments.slice(0, -1) : segments;
|
|
137
|
-
return literalSegments.every((segment) => !segment.includes(':')
|
|
138
|
-
&& !/[*?\[\]{}!]/.test(segment)
|
|
139
|
-
&& segment.toLowerCase() !== '.git');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function canonicalFlowPathArray(value, options = {}) {
|
|
143
|
-
return Array.isArray(value) && value.every((path) => isCanonicalFlowPath(path, options));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function assertContract(contract, expected = {}) {
|
|
147
|
-
if (!contract || contract.schema_version !== 1 || contract.profile !== 'FLOW') {
|
|
148
|
-
throw new TypeError('microcontrato FLOW schema_version 1 inválido');
|
|
149
|
-
}
|
|
150
|
-
safeId(contract.flow_id, 'flow_id');
|
|
151
|
-
safeId(contract.session_id, 'session_id');
|
|
152
|
-
const requiredStrings = ['session_file', 'slug', 'started_at', 'reason', 'sensor_definition_hash', 'project_rel'];
|
|
153
|
-
const malformed = requiredStrings.some((key) => typeof contract[key] !== 'string' || !contract[key].trim())
|
|
154
|
-
|| contract.spec_impact !== 'none'
|
|
155
|
-
|| !Array.isArray(contract.allowed_paths) || contract.allowed_paths.length === 0
|
|
156
|
-
|| !canonicalFlowPathArray(contract.allowed_paths, { allowTree: true })
|
|
157
|
-
|| !canonicalFlowPathArray(contract.protected_roots, { allowTree: true })
|
|
158
|
-
|| !Array.isArray(contract.sensor_ids) || contract.sensor_ids.length === 0
|
|
159
|
-
|| contract.sensor_ids.some((id) => typeof id !== 'string' || !id)
|
|
160
|
-
|| contract.baseline?.schema_version !== 1
|
|
161
|
-
|| typeof contract.baseline?.root !== 'string' || !contract.baseline.root
|
|
162
|
-
|| typeof contract.baseline?.head !== 'string' || !contract.baseline.head
|
|
163
|
-
|| !contract.baseline?.fingerprints || typeof contract.baseline.fingerprints !== 'object'
|
|
164
|
-
|| Array.isArray(contract.baseline?.fingerprints)
|
|
165
|
-
|| !/^[a-f0-9]{64}$/i.test(contract.baseline?.git_metadata_fingerprint || '')
|
|
166
|
-
|| !Array.isArray(contract.baseline?.hidden_index_paths)
|
|
167
|
-
|| !Array.isArray(contract.baseline?.unsafe_git_metadata_paths)
|
|
168
|
-
|| !Array.isArray(contract.baseline?.unsafe_worktree_paths)
|
|
169
|
-
|| [...(contract.baseline?.hidden_index_paths || []),
|
|
170
|
-
...(contract.baseline?.unsafe_git_metadata_paths || []),
|
|
171
|
-
...(contract.baseline?.unsafe_worktree_paths || [])]
|
|
172
|
-
.some((path) => typeof path !== 'string' || !path)
|
|
173
|
-
|| !isCanonicalFlowPath(contract.session_file)
|
|
174
|
-
|| !isCanonicalFlowPath(contract.project_rel, { allowProjectRoot: true });
|
|
175
|
-
if (malformed) throw new TypeError('microcontrato FLOW incompleto ou inválido');
|
|
176
|
-
if ((expected.flowId && contract.flow_id !== expected.flowId)
|
|
177
|
-
|| (expected.sessionId && contract.session_id !== expected.sessionId)) {
|
|
178
|
-
const error = new Error('microcontrato FLOW inconsistente com seu path no store');
|
|
179
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
180
|
-
throw error;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function assertTerminalArtifact(artifact, { flowId, status, label }) {
|
|
185
|
-
if (artifact === MISSING) return;
|
|
186
|
-
const baseMalformed = !isPlainObject(artifact)
|
|
187
|
-
|| artifact.schema_version !== 1
|
|
188
|
-
|| artifact.flow_id !== flowId
|
|
189
|
-
|| artifact.status !== status;
|
|
190
|
-
const payloadMalformed = !baseMalformed && (status === 'finished'
|
|
191
|
-
? !validReceipt(artifact)
|
|
192
|
-
: !validPromotion(artifact));
|
|
193
|
-
if (baseMalformed || payloadMalformed) throw corruptArtifact(label, flowId);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function isPlainObject(value) {
|
|
197
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function nonEmptyString(value) {
|
|
201
|
-
return typeof value === 'string' && Boolean(value.trim());
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function stringArray(value, { nonEmpty = false } = {}) {
|
|
205
|
-
return Array.isArray(value)
|
|
206
|
-
&& (!nonEmpty || value.length > 0)
|
|
207
|
-
&& value.every(nonEmptyString);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function validEvidence(value, { timestamp = false } = {}) {
|
|
211
|
-
return Array.isArray(value) && value.every((entry) => isPlainObject(entry)
|
|
212
|
-
&& nonEmptyString(entry.id)
|
|
213
|
-
&& ['green', 'red'].includes(entry.status)
|
|
214
|
-
&& nonEmptyString(entry.severity)
|
|
215
|
-
&& (!timestamp || nonEmptyString(entry.ts)));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function validReceipt(receipt) {
|
|
219
|
-
return nonEmptyString(receipt.finished_at)
|
|
220
|
-
&& nonEmptyString(receipt.reason)
|
|
221
|
-
&& stringArray(receipt.allowed_paths, { nonEmpty: true })
|
|
222
|
-
&& canonicalFlowPathArray(receipt.allowed_paths, { allowTree: true })
|
|
223
|
-
&& stringArray(receipt.sensor_ids, { nonEmpty: true })
|
|
224
|
-
&& stringArray(receipt.changed_paths, { nonEmpty: true })
|
|
225
|
-
&& canonicalFlowPathArray(receipt.changed_paths)
|
|
226
|
-
&& validEvidence(receipt.evidence, { timestamp: true })
|
|
227
|
-
&& nonEmptyString(receipt.baseline_head)
|
|
228
|
-
&& nonEmptyString(receipt.final_head);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function normalizedFlowPath(value) {
|
|
232
|
-
const normalized = String(value || '').replaceAll('\\', '/');
|
|
233
|
-
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function pathAllowedByContract(path, allowlist) {
|
|
237
|
-
if (!isCanonicalFlowPath(path)
|
|
238
|
-
|| !canonicalFlowPathArray(allowlist, { allowTree: true })) return false;
|
|
239
|
-
const candidate = normalizedFlowPath(path);
|
|
240
|
-
return allowlist.some((raw) => {
|
|
241
|
-
const allowed = normalizedFlowPath(raw);
|
|
242
|
-
if (allowed.endsWith('/**')) {
|
|
243
|
-
const prefix = allowed.slice(0, -3);
|
|
244
|
-
return candidate === prefix || candidate.startsWith(`${prefix}/`);
|
|
245
|
-
}
|
|
246
|
-
return candidate === allowed;
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function assertReceiptMatchesContract(receipt, contract, flowId) {
|
|
251
|
-
if (receipt === MISSING) return;
|
|
252
|
-
const evidenceIds = receipt.evidence.map((entry) => entry.id);
|
|
253
|
-
const matches = receipt.reason === contract.reason
|
|
254
|
-
&& canonicalText(receipt.allowed_paths) === canonicalText(contract.allowed_paths)
|
|
255
|
-
&& canonicalText(receipt.sensor_ids) === canonicalText(contract.sensor_ids)
|
|
256
|
-
&& canonicalText(evidenceIds) === canonicalText(contract.sensor_ids)
|
|
257
|
-
&& receipt.evidence.every((entry) => entry.status === 'green')
|
|
258
|
-
&& receipt.changed_paths.every((path) => pathAllowedByContract(path, contract.allowed_paths))
|
|
259
|
-
&& receipt.baseline_head === contract.baseline.head;
|
|
260
|
-
if (!matches) throw corruptArtifact('recibo incompatível com o contrato', flowId);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function validPromotion(promotion) {
|
|
264
|
-
return nonEmptyString(promotion.promoted_at)
|
|
265
|
-
&& nonEmptyString(promotion.change_slug)
|
|
266
|
-
&& nonEmptyString(promotion.change_rel)
|
|
267
|
-
&& isCanonicalFlowPath(promotion.change_rel)
|
|
268
|
-
&& nonEmptyString(promotion.origin_file)
|
|
269
|
-
&& isCanonicalFlowPath(promotion.origin_file)
|
|
270
|
-
&& stringArray(promotion.changed_paths)
|
|
271
|
-
&& canonicalFlowPathArray(promotion.changed_paths)
|
|
272
|
-
&& nonEmptyString(promotion.baseline_head)
|
|
273
|
-
&& nonEmptyString(promotion.current_head);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function expectedChangeRel(vaultBase, slug) {
|
|
277
|
-
return `${String(getLocale(vaultBase).folders.changes).replaceAll('\\', '/')}/${slug}`;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function validPromotionReservation(vaultBase, reservation, flowId) {
|
|
281
|
-
const origin = reservation?.origin;
|
|
282
|
-
let slug = '';
|
|
283
|
-
try {
|
|
284
|
-
assertContract(origin?.contract, { flowId });
|
|
285
|
-
slug = safeId(reservation?.change_slug, 'change_slug');
|
|
286
|
-
} catch {
|
|
287
|
-
return false;
|
|
288
|
-
}
|
|
289
|
-
return isPlainObject(reservation)
|
|
290
|
-
&& reservation.schema_version === 1
|
|
291
|
-
&& reservation.flow_id === flowId
|
|
292
|
-
&& reservation.status === 'promoting'
|
|
293
|
-
&& nonEmptyString(reservation.reserved_at)
|
|
294
|
-
&& reservation.change_slug === slug
|
|
295
|
-
&& reservation.change_rel === expectedChangeRel(vaultBase, slug)
|
|
296
|
-
&& isPlainObject(origin)
|
|
297
|
-
&& origin.schema_version === 1
|
|
298
|
-
&& origin.flow_id === flowId
|
|
299
|
-
&& origin.promoted_at === reservation.reserved_at
|
|
300
|
-
&& Array.isArray(origin.attempts)
|
|
301
|
-
&& isPlainObject(origin.observed_git)
|
|
302
|
-
&& nonEmptyString(origin.observed_git.baseline_head)
|
|
303
|
-
&& nonEmptyString(origin.observed_git.current_head)
|
|
304
|
-
&& typeof origin.observed_git.head_changed === 'boolean'
|
|
305
|
-
&& stringArray(origin.observed_git.changed_paths)
|
|
306
|
-
&& canonicalFlowPathArray(origin.observed_git.changed_paths);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function assertPromotionReservation(vaultBase, reservation, flowId) {
|
|
310
|
-
if (reservation === MISSING) return;
|
|
311
|
-
if (!validPromotionReservation(vaultBase, reservation, flowId)) {
|
|
312
|
-
throw corruptArtifact('reserva de promoção', flowId);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function assertReservationMatchesFlow(vaultBase, reservation, contract, attempts, flowId) {
|
|
317
|
-
if (reservation === MISSING) return;
|
|
318
|
-
assertPromotionReservation(vaultBase, reservation, flowId);
|
|
319
|
-
const sameContract = canonicalText(reservation.origin.contract) === canonicalText(contract);
|
|
320
|
-
const sameAttempts = attempts === undefined
|
|
321
|
-
|| canonicalText(reservation.origin.attempts) === canonicalText(attempts);
|
|
322
|
-
const observed = reservation.origin.observed_git;
|
|
323
|
-
const sameGitOrigin = observed.baseline_head === contract.baseline.head
|
|
324
|
-
&& observed.head_changed === (observed.current_head !== observed.baseline_head);
|
|
325
|
-
if (!sameContract || !sameAttempts || !sameGitOrigin) {
|
|
326
|
-
throw corruptArtifact('reserva de promoção incompatível com contrato/tentativas/Git observado', flowId);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function assertPromotionMatchesReservation(promotion, reservation, flowId) {
|
|
331
|
-
if (promotion === MISSING) return;
|
|
332
|
-
if (reservation === MISSING) throw corruptArtifact('promoção sem reserva durável', flowId);
|
|
333
|
-
const origin = reservation.origin;
|
|
334
|
-
const matches = promotion.promoted_at === reservation.reserved_at
|
|
335
|
-
&& promotion.change_slug === reservation.change_slug
|
|
336
|
-
&& promotion.change_rel === reservation.change_rel
|
|
337
|
-
&& promotion.origin_file === `${reservation.change_rel.replaceAll('\\', '/')}/flow-origin.json`
|
|
338
|
-
&& canonicalText(promotion.changed_paths) === canonicalText(origin.observed_git.changed_paths)
|
|
339
|
-
&& promotion.baseline_head === origin.observed_git.baseline_head
|
|
340
|
-
&& promotion.current_head === origin.observed_git.current_head;
|
|
341
|
-
if (!matches) throw corruptArtifact('promoção incompatível com sua reserva', flowId);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function corruptArtifact(label, flowId) {
|
|
345
|
-
const error = new Error(`${label} FLOW inválido para ${flowId}`);
|
|
346
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
347
|
-
return error;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function assertAttempt(attempt, { flowId, attemptId }) {
|
|
351
|
-
let validId = false;
|
|
352
|
-
try {
|
|
353
|
-
validId = safeId(attempt?.attempt_id, 'attempt_id') === attemptId;
|
|
354
|
-
} catch {
|
|
355
|
-
validId = false;
|
|
356
|
-
}
|
|
357
|
-
if (!isPlainObject(attempt)
|
|
358
|
-
|| attempt.schema_version !== 1
|
|
359
|
-
|| !validId
|
|
360
|
-
|| attempt.status !== 'red'
|
|
361
|
-
|| !nonEmptyString(attempt.recorded_at)
|
|
362
|
-
|| !stringArray(attempt.failures, { nonEmpty: true })
|
|
363
|
-
|| !stringArray(attempt.changed_paths)
|
|
364
|
-
|| !canonicalFlowPathArray(attempt.changed_paths)
|
|
365
|
-
|| !validEvidence(attempt.evidence)) {
|
|
366
|
-
throw corruptArtifact('tentativa', flowId);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export function createFlowContract(vaultBase, contract) {
|
|
371
|
-
assertContract(contract);
|
|
372
|
-
return underSessionLock(vaultBase, contract.session_id, () => {
|
|
373
|
-
const dir = flowDir(vaultBase, contract.session_id, contract.flow_id);
|
|
374
|
-
mkdirVaultPath(vaultBase, dir, { label: 'raiz runtime do FLOW' });
|
|
375
|
-
const existing = readJson(vaultBase, join(dir, 'contract.json'), 'contrato FLOW');
|
|
376
|
-
if (existing !== MISSING) return immutableJson(vaultBase, join(dir, 'contract.json'), contract);
|
|
377
|
-
const active = findActiveFlow(vaultBase, contract.session_id);
|
|
378
|
-
if (active) {
|
|
379
|
-
const error = new Error(`FLOW ativo já existe para sessão ${contract.session_id}: ${active.contract.flow_id}`);
|
|
380
|
-
error.code = 'FLOW_ALREADY_ACTIVE';
|
|
381
|
-
throw error;
|
|
382
|
-
}
|
|
383
|
-
return immutableJson(vaultBase, join(dir, 'contract.json'), contract);
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export function readFlow(vaultBase, { sessionId, flowId }) {
|
|
388
|
-
const dir = flowDir(vaultBase, sessionId, flowId);
|
|
389
|
-
const contract = readJson(vaultBase, join(dir, 'contract.json'), 'contrato FLOW');
|
|
390
|
-
if (contract === MISSING) return null;
|
|
391
|
-
assertContract(contract, { sessionId, flowId });
|
|
392
|
-
const receiptArtifact = readJson(vaultBase, join(dir, 'receipt.json'), 'recibo FLOW');
|
|
393
|
-
const promotionArtifact = readJson(vaultBase, join(dir, 'promotion.json'), 'promoção FLOW');
|
|
394
|
-
const reservationArtifact = readJson(vaultBase, join(dir, 'promotion-reservation.json'), 'reserva de promoção FLOW');
|
|
395
|
-
assertTerminalArtifact(receiptArtifact, { flowId, status: 'finished', label: 'recibo' });
|
|
396
|
-
assertTerminalArtifact(promotionArtifact, { flowId, status: 'promoted', label: 'promoção' });
|
|
397
|
-
assertPromotionReservation(vaultBase, reservationArtifact, flowId);
|
|
398
|
-
assertReceiptMatchesContract(receiptArtifact, contract, flowId);
|
|
399
|
-
const hasReceipt = receiptArtifact !== MISSING;
|
|
400
|
-
const hasPromotion = promotionArtifact !== MISSING;
|
|
401
|
-
const hasReservation = reservationArtifact !== MISSING;
|
|
402
|
-
if (hasReservation) {
|
|
403
|
-
assertReservationMatchesFlow(vaultBase, reservationArtifact, contract, undefined, flowId);
|
|
404
|
-
}
|
|
405
|
-
if (hasReceipt && hasPromotion) {
|
|
406
|
-
const error = new Error(`FLOW corrompido: recibo e promoção coexistem em ${flowId}`);
|
|
407
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
408
|
-
throw error;
|
|
409
|
-
}
|
|
410
|
-
if (hasReceipt && hasReservation) {
|
|
411
|
-
const error = new Error(`FLOW corrompido: recibo e reserva de promoção coexistem em ${flowId}`);
|
|
412
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
413
|
-
throw error;
|
|
414
|
-
}
|
|
415
|
-
if (hasPromotion && !hasReservation) {
|
|
416
|
-
throw corruptArtifact('promoção sem reserva durável', flowId);
|
|
417
|
-
}
|
|
418
|
-
const attemptsDir = join(dir, 'attempts');
|
|
419
|
-
const checkedAttempts = assertVaultPathSafe(vaultBase, attemptsDir, {
|
|
420
|
-
expectedType: 'directory', label: 'raiz de tentativas FLOW',
|
|
421
|
-
});
|
|
422
|
-
const attempts = checkedAttempts.exists
|
|
423
|
-
? readdirSync(attemptsDir).filter((name) => name.endsWith('.json')).sort()
|
|
424
|
-
.map((name) => {
|
|
425
|
-
const attempt = readJson(vaultBase, join(attemptsDir, name), 'tentativa FLOW');
|
|
426
|
-
assertAttempt(attempt, { flowId, attemptId: name.slice(0, -'.json'.length) });
|
|
427
|
-
return attempt;
|
|
428
|
-
})
|
|
429
|
-
: [];
|
|
430
|
-
if (hasReservation) {
|
|
431
|
-
assertReservationMatchesFlow(vaultBase, reservationArtifact, contract, attempts, flowId);
|
|
432
|
-
}
|
|
433
|
-
if (hasPromotion) {
|
|
434
|
-
assertPromotionMatchesReservation(promotionArtifact, reservationArtifact, flowId);
|
|
435
|
-
}
|
|
436
|
-
return {
|
|
437
|
-
state: hasReceipt ? 'finished' : hasPromotion ? 'promoted' : hasReservation ? 'promoting' : 'active',
|
|
438
|
-
contract,
|
|
439
|
-
attempts,
|
|
440
|
-
receipt: hasReceipt ? receiptArtifact : null,
|
|
441
|
-
promotion: hasPromotion ? promotionArtifact : null,
|
|
442
|
-
reservation: hasReservation ? reservationArtifact : null,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
export function listFlows(vaultBase, { sessionId = '' } = {}) {
|
|
447
|
-
const root = runtimeRoot(vaultBase);
|
|
448
|
-
const checkedRoot = assertVaultPathSafe(vaultBase, root, {
|
|
449
|
-
expectedType: 'directory', label: 'raiz runtime de FLOW',
|
|
450
|
-
});
|
|
451
|
-
if (!checkedRoot.exists) return [];
|
|
452
|
-
const sessions = sessionId ? [safeId(sessionId, 'session_id')] : readdirSync(root).sort();
|
|
453
|
-
const result = [];
|
|
454
|
-
for (const sid of sessions) {
|
|
455
|
-
const dir = sessionRoot(vaultBase, sid);
|
|
456
|
-
const checkedSession = assertVaultPathSafe(vaultBase, dir, {
|
|
457
|
-
expectedType: 'directory', label: 'raiz runtime da sessão FLOW',
|
|
458
|
-
});
|
|
459
|
-
if (!checkedSession.exists) continue;
|
|
460
|
-
for (const flowId of readdirSync(dir).sort()) {
|
|
461
|
-
if (flowId.startsWith('.')) continue;
|
|
462
|
-
const flow = readFlow(vaultBase, { sessionId: sid, flowId });
|
|
463
|
-
if (flow) result.push(flow);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
return result;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
export function findFlow(vaultBase, flowId, { sessionId = '' } = {}) {
|
|
470
|
-
const matches = listFlows(vaultBase, { sessionId }).filter((flow) => flow.contract.flow_id === flowId);
|
|
471
|
-
if (matches.length > 1) throw new Error(`flow_id ambíguo: ${flowId}`);
|
|
472
|
-
return matches[0] || null;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
export function findActiveFlow(vaultBase, sessionId) {
|
|
476
|
-
const active = listFlows(vaultBase, { sessionId })
|
|
477
|
-
.filter((flow) => flow.state === 'active' || flow.state === 'promoting');
|
|
478
|
-
if (active.length > 1) {
|
|
479
|
-
const error = new Error(`mais de um FLOW ativo para sessão ${sessionId}`);
|
|
480
|
-
error.code = 'FLOW_STORE_CORRUPT';
|
|
481
|
-
throw error;
|
|
482
|
-
}
|
|
483
|
-
return active[0] || null;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export function reserveFlowPromotion(vaultBase, sessionId, flowId, reservation) {
|
|
487
|
-
assertPromotionReservation(vaultBase, reservation, flowId);
|
|
488
|
-
return underSessionLock(vaultBase, sessionId, () => {
|
|
489
|
-
const state = readFlow(vaultBase, { sessionId, flowId });
|
|
490
|
-
if (!state) throw new Error(`FLOW não encontrado: ${flowId}`);
|
|
491
|
-
if (state.state === 'finished') throw new Error(`FLOW já finalizado: ${flowId}`);
|
|
492
|
-
if (state.state === 'promoted') throw new Error(`FLOW já promovido: ${flowId}`);
|
|
493
|
-
try {
|
|
494
|
-
assertReservationMatchesFlow(
|
|
495
|
-
vaultBase,
|
|
496
|
-
reservation,
|
|
497
|
-
state.contract,
|
|
498
|
-
undefined,
|
|
499
|
-
flowId,
|
|
500
|
-
);
|
|
501
|
-
} catch (error) {
|
|
502
|
-
if (error?.code === 'FLOW_STORE_CORRUPT') throw error;
|
|
503
|
-
throw corruptArtifact('reserva incompatível com o contrato', flowId);
|
|
504
|
-
}
|
|
505
|
-
if (state.state === 'active'
|
|
506
|
-
&& canonicalText(reservation.origin.attempts) !== canonicalText(state.attempts)) {
|
|
507
|
-
const error = new Error(`tentativas FLOW mudaram antes da reserva: ${flowId}`);
|
|
508
|
-
error.code = 'FLOW_PROMOTION_STALE';
|
|
509
|
-
throw error;
|
|
510
|
-
}
|
|
511
|
-
return immutableJson(
|
|
512
|
-
vaultBase,
|
|
513
|
-
join(flowDir(vaultBase, sessionId, flowId), 'promotion-reservation.json'),
|
|
514
|
-
reservation,
|
|
515
|
-
);
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
export function appendFlowAttempt(vaultBase, sessionId, flowId, attempt) {
|
|
520
|
-
const attemptId = safeId(attempt?.attempt_id, 'attempt_id');
|
|
521
|
-
assertAttempt(attempt, { flowId, attemptId });
|
|
522
|
-
return underSessionLock(vaultBase, sessionId, () => {
|
|
523
|
-
const state = readFlow(vaultBase, { sessionId, flowId });
|
|
524
|
-
if (!state) throw new Error(`FLOW não encontrado: ${flowId}`);
|
|
525
|
-
if (state.state !== 'active') throw new Error(`FLOW já finalizado: ${flowId}`);
|
|
526
|
-
const dir = join(flowDir(vaultBase, sessionId, flowId), 'attempts');
|
|
527
|
-
mkdirVaultPath(vaultBase, dir, { label: 'raiz de tentativas FLOW' });
|
|
528
|
-
return immutableJson(vaultBase, join(dir, `${attemptId}.json`), attempt);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
export function writeFlowReceipt(vaultBase, sessionId, flowId, receipt) {
|
|
533
|
-
return underSessionLock(vaultBase, sessionId, () => {
|
|
534
|
-
const state = readFlow(vaultBase, { sessionId, flowId });
|
|
535
|
-
if (!state) throw new Error(`FLOW não encontrado: ${flowId}`);
|
|
536
|
-
if (state.promotion) throw new Error(`FLOW já promovido: ${flowId}`);
|
|
537
|
-
if (state.reservation) {
|
|
538
|
-
const error = new Error(`FLOW em promoção: ${flowId}`);
|
|
539
|
-
error.code = 'FLOW_TERMINAL';
|
|
540
|
-
throw error;
|
|
541
|
-
}
|
|
542
|
-
assertTerminalArtifact(receipt, { flowId, status: 'finished', label: 'recibo' });
|
|
543
|
-
assertReceiptMatchesContract(receipt, state.contract, flowId);
|
|
544
|
-
return immutableJson(vaultBase, join(flowDir(vaultBase, sessionId, flowId), 'receipt.json'), receipt);
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
export function writeFlowPromotion(vaultBase, sessionId, flowId, promotion) {
|
|
549
|
-
return underSessionLock(vaultBase, sessionId, () => {
|
|
550
|
-
const state = readFlow(vaultBase, { sessionId, flowId });
|
|
551
|
-
if (!state) throw new Error(`FLOW não encontrado: ${flowId}`);
|
|
552
|
-
if (state.receipt) throw new Error(`FLOW já finalizado: ${flowId}`);
|
|
553
|
-
assertTerminalArtifact(promotion, { flowId, status: 'promoted', label: 'promoção' });
|
|
554
|
-
if (!state.reservation) throw corruptArtifact('promoção sem reserva durável', flowId);
|
|
555
|
-
assertPromotionMatchesReservation(promotion, state.reservation, flowId);
|
|
556
|
-
return immutableJson(vaultBase, join(flowDir(vaultBase, sessionId, flowId), 'promotion.json'), promotion);
|
|
557
|
-
});
|
|
558
|
-
}
|
|
1
|
+
export * from '../packages/harness/src/flow-store.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
|
|
34
|
+
"check": "node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
|
|
35
35
|
"test": "node --test",
|
|
36
36
|
"release": "node scripts/release.mjs",
|
|
37
37
|
"release:dry": "node scripts/release.mjs --dry-run",
|