wendkeep 0.68.6 → 0.70.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 +23 -0
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/docs/en/commands/changes-and-verification.md +4 -0
- package/docs/en/commands/costs-and-observability.md +11 -2
- package/docs/en/commands/memory.md +10 -1
- package/docs/en/commands/observer.md +104 -0
- package/docs/pt-BR/commands/changes-and-verification.md +4 -0
- package/docs/pt-BR/commands/costs-and-observability.md +12 -2
- package/docs/pt-BR/commands/memory.md +10 -1
- package/docs/pt-BR/commands/observer.md +105 -0
- package/hooks/brain-core.mjs +46 -2
- package/hooks/brain-inject.mjs +3 -3
- package/hooks/harness-doctor.mjs +21 -7
- package/hooks/observer-publish.mjs +21 -0
- package/hooks/pricing.json +10 -1
- package/hooks/session-ensure.mjs +23 -0
- package/hooks/session-identity.mjs +4 -2
- package/hooks/session-stop.mjs +17 -0
- package/hooks/token-usage.mjs +13 -0
- package/hooks/vault-health.mjs +13 -0
- package/package.json +3 -3
- package/packages/cli/src/index.mjs +9 -2
- package/packages/integrations/src/host-hooks.mjs +4 -0
- package/packages/vault/src/memory-handoff.mjs +75 -9
- package/packages/vault/src/memory-store.mjs +34 -6
- package/packages/vault/src/validate-core.mjs +29 -15
- package/packages/vault/src/validate-memory.mjs +209 -6
- package/src/doctor.mjs +4 -0
- package/src/memory.mjs +4 -1
- package/src/observer-publish.mjs +122 -0
- package/src/observer-server.mjs +203 -0
- package/src/observer-snapshot.mjs +153 -0
- package/src/observer-store.mjs +155 -0
- package/src/observer.mjs +108 -0
- package/src/taxonomy.mjs +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { sanitizeMemoryText, validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
|
|
4
|
+
import { deriveMemoryProjection } from './memory-store.mjs';
|
|
4
5
|
import { assertVaultPathSafe } from './vault-path-safety.mjs';
|
|
5
6
|
import { validateCore } from './validate-core.mjs';
|
|
6
7
|
|
|
@@ -104,8 +105,208 @@ function validateSharedArtifact(vaultBase, eventIds) {
|
|
|
104
105
|
return { ...validateSharedMemory(read.content, { eventIds }), path, content: read.content };
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const TERMINAL_CANDIDATE_STATUSES = new Set(['resolved', 'rejected', 'superseded']);
|
|
109
|
+
const DECISION_LINK_RE = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
110
|
+
|
|
111
|
+
function readCandidateInventory(vaultBase) {
|
|
112
|
+
const path = join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl');
|
|
113
|
+
let checked;
|
|
114
|
+
try {
|
|
115
|
+
checked = assertVaultPathSafe(vaultBase, path, {
|
|
116
|
+
allowMissing: true, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return failedComponent(`MEMORY_CANDIDATES.jsonl inseguro: ${error?.message || error}`, {
|
|
120
|
+
count: 0, activeCount: 0, path,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!checked.exists) return { ok: true, errors: [], warnings: [], count: 0, activeCount: 0, path };
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
checked = assertVaultPathSafe(vaultBase, checked.target, {
|
|
127
|
+
allowMissing: false, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
|
|
128
|
+
});
|
|
129
|
+
const lines = readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n')
|
|
130
|
+
.split('\n').filter((line) => line.trim());
|
|
131
|
+
const errors = [];
|
|
132
|
+
let count = 0;
|
|
133
|
+
let activeCount = 0;
|
|
134
|
+
for (const [index, line] of lines.entries()) {
|
|
135
|
+
try {
|
|
136
|
+
const item = JSON.parse(line);
|
|
137
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
138
|
+
errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} deve conter um objeto.`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
count += 1;
|
|
142
|
+
if (!TERMINAL_CANDIDATE_STATUSES.has(item.status || 'active')) activeCount += 1;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} contém JSON inválido: ${error?.message || error}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: errors.length === 0, errors, warnings: [], count, activeCount, path };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return failedComponent(`MEMORY_CANDIDATES.jsonl ilegível: ${error?.message || error}`, {
|
|
150
|
+
count: 0, activeCount: 0, path,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sharedEventIds(content) {
|
|
156
|
+
const ids = new Set();
|
|
157
|
+
for (const line of String(content || '').split('\n')) {
|
|
158
|
+
const match = line.match(/^\s*-\s+\[([^\]]+)\]/);
|
|
159
|
+
if (match?.[1]) ids.add(match[1]);
|
|
160
|
+
}
|
|
161
|
+
return ids;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function walkDecisionFiles(root, vaultBase, output = []) {
|
|
165
|
+
let entries;
|
|
166
|
+
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return output; }
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const path = join(root, entry.name);
|
|
169
|
+
if (entry.isDirectory()) walkDecisionFiles(path, vaultBase, output);
|
|
170
|
+
else if (entry.isFile() && /^ADR-\d+.*\.md$/i.test(entry.name)) {
|
|
171
|
+
const rel = relative(vaultBase, path).replace(/\\/g, '/').replace(/\.md$/i, '');
|
|
172
|
+
output.push(rel);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return output;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function decisionInventory(vaultBase) {
|
|
179
|
+
const paths = [];
|
|
180
|
+
for (const folder of ['04-Decisões', '04-Decisions']) {
|
|
181
|
+
walkDecisionFiles(join(vaultBase, folder), vaultBase, paths);
|
|
182
|
+
}
|
|
183
|
+
const byPath = new Set(paths);
|
|
184
|
+
const byBasename = new Set(paths.map((path) => basename(path)));
|
|
185
|
+
return { byPath, byBasename };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function unresolvedDecisionLinks(vaultBase, content) {
|
|
189
|
+
const inventory = decisionInventory(vaultBase);
|
|
190
|
+
const unresolved = new Set();
|
|
191
|
+
DECISION_LINK_RE.lastIndex = 0;
|
|
192
|
+
let match;
|
|
193
|
+
while ((match = DECISION_LINK_RE.exec(String(content || '')))) {
|
|
194
|
+
const target = String(match[1] || '').trim().replace(/\\/g, '/').replace(/\.md$/i, '');
|
|
195
|
+
const targetBase = basename(target);
|
|
196
|
+
const isDecision = /^ADR-\d+/i.test(targetBase)
|
|
197
|
+
|| /(^|\/)(?:04-Decis(?:õ|o)es|04-Decisions)(?:\/|$)/i.test(target);
|
|
198
|
+
if (!isDecision) continue;
|
|
199
|
+
if (!target || target.includes('...') || target.includes('…')
|
|
200
|
+
|| (!inventory.byPath.has(target) && !inventory.byBasename.has(targetBase))) {
|
|
201
|
+
unresolved.add(sanitizeMemoryText(target).slice(0, 180));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return [...unresolved].sort();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
|
|
208
|
+
const base = {
|
|
209
|
+
ok: true,
|
|
210
|
+
status: 'unavailable',
|
|
211
|
+
code: 'MEMORY_SEMANTIC_STRUCTURAL_UNAVAILABLE',
|
|
212
|
+
errors: [],
|
|
213
|
+
warnings: [],
|
|
214
|
+
activeKeys: [],
|
|
215
|
+
projectedKeys: [],
|
|
216
|
+
missingKeys: [],
|
|
217
|
+
unresolvedDecisionLinks: [],
|
|
218
|
+
placeholderOnly: false,
|
|
219
|
+
counts: { activeKeys: 0, projectedKeys: 0, missingKeys: 0, candidates: candidates?.activeCount || 0, placeholderSections: 0, unresolvedDecisionLinks: 0 },
|
|
220
|
+
};
|
|
221
|
+
if (!ledger?.ok || !shared?.ok) return base;
|
|
222
|
+
|
|
223
|
+
let replay;
|
|
224
|
+
try { replay = deriveMemoryProjection(vaultBase, ledger.events); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
ok: false,
|
|
229
|
+
status: 'degraded',
|
|
230
|
+
code: 'MEMORY_SEMANTIC_REPLAY_UNAVAILABLE',
|
|
231
|
+
errors: ['[MEMORY_SEMANTIC_REPLAY_UNAVAILABLE] não foi possível rederivar as chaves ativas do ledger.'],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const active = Object.entries(replay.records || {}).map(([memoryKey, record]) => ({
|
|
236
|
+
memoryKey: sanitizeMemoryText(memoryKey),
|
|
237
|
+
eventId: record?.source?.event_id,
|
|
238
|
+
}));
|
|
239
|
+
const activeKeys = active.map(({ memoryKey }) => memoryKey).sort();
|
|
240
|
+
const projectedIds = sharedEventIds(shared.content);
|
|
241
|
+
const projectedKeys = active
|
|
242
|
+
.filter(({ eventId }) => projectedIds.has(eventId))
|
|
243
|
+
.map(({ memoryKey }) => memoryKey)
|
|
244
|
+
.sort();
|
|
245
|
+
const missingKeys = active
|
|
246
|
+
.filter(({ eventId }) => !projectedIds.has(eventId))
|
|
247
|
+
.map(({ memoryKey }) => memoryKey)
|
|
248
|
+
.sort();
|
|
249
|
+
const sectionValues = [...(shared.sections?.values?.() || [])].flat();
|
|
250
|
+
const placeholderSections = sectionValues.filter((line) => /^-\s*\(vazio\)\s*$/i.test(line)).length;
|
|
251
|
+
const nonPlaceholderLines = sectionValues.filter((line) => !/^-\s*\(vazio\)\s*$/i.test(line));
|
|
252
|
+
const placeholderOnly = sectionValues.length > 0 && nonPlaceholderLines.length === 0;
|
|
253
|
+
const unresolvedLinks = unresolvedDecisionLinks(vaultBase, shared.content);
|
|
254
|
+
const candidateCount = Math.max(candidates?.activeCount || 0, replay.candidates?.length || 0);
|
|
255
|
+
const errors = [];
|
|
256
|
+
const warnings = [];
|
|
257
|
+
const codes = [];
|
|
258
|
+
|
|
259
|
+
if (missingKeys.length && placeholderOnly) {
|
|
260
|
+
codes.push('MEMORY_SEMANTIC_PLACEHOLDER_ONLY');
|
|
261
|
+
errors.push(`[MEMORY_SEMANTIC_PLACEHOLDER_ONLY] SHARED contém somente placeholders para ${activeKeys.length} chave(s) ativa(s); candidates=${candidateCount}.`);
|
|
262
|
+
} else if (missingKeys.length) {
|
|
263
|
+
codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
|
|
264
|
+
errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
|
|
265
|
+
}
|
|
266
|
+
if (unresolvedLinks.length) {
|
|
267
|
+
codes.push('MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED');
|
|
268
|
+
errors.push(`[MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED] ${unresolvedLinks.length} link(s) de decisão não resolvido(s).`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let status = 'healthy';
|
|
272
|
+
let code = 'MEMORY_SEMANTIC_COVERAGE_OK';
|
|
273
|
+
if (!ledger.events.length && !candidateCount) {
|
|
274
|
+
status = 'neutral';
|
|
275
|
+
code = 'MEMORY_SEMANTIC_EMPTY_NEUTRAL';
|
|
276
|
+
} else if (!activeKeys.length && candidateCount) {
|
|
277
|
+
status = 'degraded';
|
|
278
|
+
code = 'MEMORY_SEMANTIC_CANDIDATES_PENDING';
|
|
279
|
+
warnings.push(`[MEMORY_SEMANTIC_CANDIDATES_PENDING] ${candidateCount} candidate(s) preservado(s); o estado não é memória vazia.`);
|
|
280
|
+
} else if (codes.length) {
|
|
281
|
+
status = 'degraded';
|
|
282
|
+
code = codes[0];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ok: errors.length === 0,
|
|
287
|
+
status,
|
|
288
|
+
code,
|
|
289
|
+
codes,
|
|
290
|
+
errors,
|
|
291
|
+
warnings,
|
|
292
|
+
activeKeys,
|
|
293
|
+
projectedKeys,
|
|
294
|
+
missingKeys,
|
|
295
|
+
unresolvedDecisionLinks: unresolvedLinks,
|
|
296
|
+
placeholderOnly,
|
|
297
|
+
counts: {
|
|
298
|
+
activeKeys: activeKeys.length,
|
|
299
|
+
projectedKeys: projectedKeys.length,
|
|
300
|
+
missingKeys: missingKeys.length,
|
|
301
|
+
candidates: candidateCount,
|
|
302
|
+
placeholderSections,
|
|
303
|
+
unresolvedDecisionLinks: unresolvedLinks.length,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function combineMemoryResults({ project, core, ledger, shared, candidates, semantic }) {
|
|
309
|
+
const components = { project, core, ledger, shared, candidates, semantic };
|
|
109
310
|
const errors = [];
|
|
110
311
|
const warnings = [];
|
|
111
312
|
for (const [name, result] of Object.entries(components)) {
|
|
@@ -124,5 +325,7 @@ export function validateMemoryBundle(vaultBase) {
|
|
|
124
325
|
const core = validateCoreArtifact(vaultBase);
|
|
125
326
|
const ledger = readLedgerForValidation(vaultBase, { projectId: project.projectId });
|
|
126
327
|
const shared = validateSharedArtifact(vaultBase, ledger.eventIds);
|
|
127
|
-
|
|
328
|
+
const candidates = readCandidateInventory(vaultBase);
|
|
329
|
+
const semantic = semanticMemoryHealth(vaultBase, { ledger, shared, candidates });
|
|
330
|
+
return combineMemoryResults({ project, core, ledger, shared, candidates, semantic });
|
|
128
331
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -39,6 +39,10 @@ export function renderVaultHealthLines(result) {
|
|
|
39
39
|
`[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
|
|
40
40
|
);
|
|
41
41
|
lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
|
|
42
|
+
const semanticKeys = memory.semanticActiveKeys || [];
|
|
43
|
+
const semanticProjected = memory.semanticProjectedKeys || [];
|
|
44
|
+
const semanticMissing = memory.semanticMissingKeys || [];
|
|
45
|
+
lines.push(` semântica: ${metricValue(memory.semanticCode)} · ativas: ${semanticKeys.length} [${semanticKeys.join(', ')}] · projetadas: ${semanticProjected.length} · ausentes: ${semanticMissing.length}`);
|
|
42
46
|
for (const failure of memoryFailures) lines.push(` ✗ ${failure}`);
|
|
43
47
|
for (const warning of memoryWarnings) lines.push(` ! ${warning}`);
|
|
44
48
|
if (result.memoryStatus === 'healthy' && !memoryFailures.length && !memoryWarnings.length) {
|
package/src/memory.mjs
CHANGED
|
@@ -1874,13 +1874,16 @@ export function runValidateMemoryBundle(argv) {
|
|
|
1874
1874
|
return;
|
|
1875
1875
|
}
|
|
1876
1876
|
const result = validateMemoryBundle(vault);
|
|
1877
|
+
const semantic = result.semantic || {};
|
|
1878
|
+
const semanticSummary = `semântica ${semantic.code || 'n/a'} · ativas: ${semantic.counts?.activeKeys ?? 0} · projetadas: ${semantic.counts?.projectedKeys ?? 0} · ausentes: ${semantic.counts?.missingKeys ?? 0}`;
|
|
1877
1879
|
if (!result.ok) {
|
|
1878
1880
|
process.stderr.write(`❌ bundle de memória inválido (${result.errors.length} erro(s)):\n`);
|
|
1881
|
+
process.stderr.write(` ${semanticSummary}\n`);
|
|
1879
1882
|
for (const error of result.errors) process.stderr.write(` - ${error}\n`);
|
|
1880
1883
|
process.exitCode = 1;
|
|
1881
1884
|
return;
|
|
1882
1885
|
}
|
|
1883
|
-
process.stdout.write(
|
|
1886
|
+
process.stdout.write(`✅ bundle de memória v2 OK (CORE + ledger + SHARED; ${semanticSummary}).\n`);
|
|
1884
1887
|
process.exitCode = 0;
|
|
1885
1888
|
}
|
|
1886
1889
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildProjectSnapshot } from './observer-snapshot.mjs';
|
|
4
|
+
|
|
5
|
+
const OUTBOX_REL = join('.brain', 'observer-outbox');
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 500;
|
|
7
|
+
|
|
8
|
+
function outboxDir(vaultBase) {
|
|
9
|
+
return join(vaultBase, OUTBOX_REL);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function eventPath(vaultBase, eventId) {
|
|
13
|
+
if (!/^obs-[a-f0-9]{24}$/.test(eventId)) throw new Error('event_id inválido para outbox.');
|
|
14
|
+
return join(outboxDir(vaultBase), `${eventId}.json`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function atomicWrite(path, value) {
|
|
18
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
19
|
+
writeFileSync(temp, `${JSON.stringify(value)}\n`, 'utf8');
|
|
20
|
+
renameSync(temp, path);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listOutbox(vaultBase) {
|
|
24
|
+
const dir = outboxDir(vaultBase);
|
|
25
|
+
if (!existsSync(dir)) return [];
|
|
26
|
+
return readdirSync(dir)
|
|
27
|
+
.filter((name) => /^obs-[a-f0-9]{24}\.json$/.test(name))
|
|
28
|
+
.sort()
|
|
29
|
+
.flatMap((name) => {
|
|
30
|
+
try { return [JSON.parse(readFileSync(join(dir, name), 'utf8'))]; }
|
|
31
|
+
catch { return []; }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function queueOutbox(vaultBase, event) {
|
|
36
|
+
const dir = outboxDir(vaultBase);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const path = eventPath(vaultBase, event.event_id);
|
|
39
|
+
if (!existsSync(path)) atomicWrite(path, event);
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function removeOutbox(vaultBase, eventId) {
|
|
44
|
+
const path = eventPath(vaultBase, eventId);
|
|
45
|
+
if (existsSync(path)) unlinkSync(path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function postSnapshot(url, token, event) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(event.project_id)}/snapshot`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${token}`,
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(event),
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
let body = {};
|
|
63
|
+
try { body = JSON.parse(text); } catch { /* server error remains deterministic below */ }
|
|
64
|
+
if (!response.ok || !(body.accepted === true || body.duplicate === true)) {
|
|
65
|
+
throw new Error(`Observer respondeu HTTP ${response.status}.`);
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function retryObserverOutbox({ vaultBase, url, token } = {}) {
|
|
74
|
+
if (!url || !token) return { attempted: 0, confirmed: 0, pending: listOutbox(vaultBase).length };
|
|
75
|
+
let attempted = 0;
|
|
76
|
+
let confirmed = 0;
|
|
77
|
+
for (const event of listOutbox(vaultBase)) {
|
|
78
|
+
attempted += 1;
|
|
79
|
+
try {
|
|
80
|
+
await postSnapshot(url, token, event);
|
|
81
|
+
removeOutbox(vaultBase, event.event_id);
|
|
82
|
+
confirmed += 1;
|
|
83
|
+
} catch { /* preserve the event for a later retry */ }
|
|
84
|
+
}
|
|
85
|
+
return { attempted, confirmed, pending: listOutbox(vaultBase).length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function publishObserverSnapshot({
|
|
89
|
+
vaultBase,
|
|
90
|
+
projectRoot,
|
|
91
|
+
url = process.env.WENDKEEP_OBSERVER_URL || '',
|
|
92
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
93
|
+
now = new Date(),
|
|
94
|
+
} = {}) {
|
|
95
|
+
try {
|
|
96
|
+
const event = buildProjectSnapshot({ vaultBase, projectRoot, now });
|
|
97
|
+
if (!url) return { ok: true, skipped: true, queued: false, hookExitCode: 0, event_id: event.event_id };
|
|
98
|
+
|
|
99
|
+
await retryObserverOutbox({ vaultBase, url, token });
|
|
100
|
+
try {
|
|
101
|
+
const response = await postSnapshot(url, token, event);
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
queued: false,
|
|
105
|
+
hookExitCode: 0,
|
|
106
|
+
event_id: event.event_id,
|
|
107
|
+
duplicate: response.duplicate === true,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
queueOutbox(vaultBase, event);
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
queued: true,
|
|
114
|
+
hookExitCode: 0,
|
|
115
|
+
event_id: event.event_id,
|
|
116
|
+
error: error.message,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { ok: false, queued: false, hookExitCode: 0, error: error.message };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { appendObserverEvent, getObserverProject, readObserverIndex, registerObserverProject } from './observer-store.mjs';
|
|
3
|
+
import { MAX_SNAPSHOT_BYTES, validateObserverSnapshot } from './observer-snapshot.mjs';
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
6
|
+
const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
|
|
7
|
+
|
|
8
|
+
function loopbackOnly(host) {
|
|
9
|
+
return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function json(res, status, body) {
|
|
13
|
+
const content = JSON.stringify(body);
|
|
14
|
+
res.writeHead(status, {
|
|
15
|
+
'content-type': 'application/json; charset=utf-8',
|
|
16
|
+
'cache-control': 'no-store',
|
|
17
|
+
'content-length': Buffer.byteLength(content),
|
|
18
|
+
});
|
|
19
|
+
res.end(content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorResponse(res, status, code, message) {
|
|
23
|
+
json(res, status, { error: { code, message } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readBody(req) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let size = 0;
|
|
29
|
+
let tooLarge = false;
|
|
30
|
+
const chunks = [];
|
|
31
|
+
req.on('data', (chunk) => {
|
|
32
|
+
if (tooLarge) return;
|
|
33
|
+
size += chunk.length;
|
|
34
|
+
if (size > MAX_BODY_BYTES) {
|
|
35
|
+
tooLarge = true;
|
|
36
|
+
const error = new Error('corpo acima do limite.');
|
|
37
|
+
error.code = 'payload_too_large';
|
|
38
|
+
reject(error);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
});
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
|
45
|
+
});
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseJson(text) {
|
|
51
|
+
try { return JSON.parse(text || '{}'); }
|
|
52
|
+
catch {
|
|
53
|
+
const error = new Error('JSON inválido.');
|
|
54
|
+
error.code = 'invalid_json';
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function authorized(req, token) {
|
|
60
|
+
if (!token) return false;
|
|
61
|
+
const header = String(req.headers.authorization || '');
|
|
62
|
+
return header === `Bearer ${token}`
|
|
63
|
+
|| req.headers['x-wendkeep-observer-token'] === token;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pathParts(url) {
|
|
67
|
+
return new URL(url, 'http://127.0.0.1').pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function projectIdFrom(parts) {
|
|
71
|
+
return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function startObserverServer({
|
|
75
|
+
host = '127.0.0.1',
|
|
76
|
+
port = 8787,
|
|
77
|
+
dataDir,
|
|
78
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
79
|
+
allowNonLoopback = false,
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (!loopbackOnly(host) && !allowNonLoopback) {
|
|
82
|
+
throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
|
|
83
|
+
}
|
|
84
|
+
if (!dataDir) throw new Error('dataDir é obrigatório.');
|
|
85
|
+
const server = createServer(async (req, res) => {
|
|
86
|
+
try {
|
|
87
|
+
const parts = pathParts(req.url || '/');
|
|
88
|
+
if (req.method === 'GET' && parts.length === 1 && parts[0] === 'healthz') {
|
|
89
|
+
json(res, 200, { ok: true, service: 'wendkeep-observer', schema_version: 1 });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!authorized(req, token)) {
|
|
93
|
+
errorResponse(res, 401, 'unauthorized', 'token local ausente ou inválido.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (parts[0] !== 'v1' || parts[1] !== 'projects') {
|
|
97
|
+
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parts.length === 2 && req.method === 'GET') {
|
|
102
|
+
const index = readObserverIndex(dataDir);
|
|
103
|
+
json(res, 200, {
|
|
104
|
+
schema_version: index.schema_version,
|
|
105
|
+
projects: index.projects.map(({ snapshot, ...summary }) => summary),
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const projectId = projectIdFrom(parts);
|
|
111
|
+
if (!projectId) {
|
|
112
|
+
errorResponse(res, 404, 'not_found', 'projeto não informado.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (parts.length === 3 && req.method === 'GET') {
|
|
117
|
+
const project = getObserverProject(dataDir, projectId);
|
|
118
|
+
if (!project) {
|
|
119
|
+
errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
json(res, 200, project);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (parts.length === 3 && req.method === 'PUT') {
|
|
127
|
+
const body = parseJson(await readBody(req));
|
|
128
|
+
if (body.project_id !== projectId) {
|
|
129
|
+
errorResponse(res, 400, 'project_mismatch', 'project_id do corpo não corresponde à rota.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const result = registerObserverProject(dataDir, {
|
|
133
|
+
projectId,
|
|
134
|
+
projectName: body.project_name,
|
|
135
|
+
wendkeepVersion: body.wendkeep_version,
|
|
136
|
+
});
|
|
137
|
+
if (!result.registered) {
|
|
138
|
+
errorResponse(res, 400, 'invalid_project', result.errors.join(' '));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
json(res, 201, result.project);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (parts.length === 4 && parts[3] === 'changes' && req.method === 'GET') {
|
|
146
|
+
const project = getObserverProject(dataDir, projectId);
|
|
147
|
+
if (!project) {
|
|
148
|
+
errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
json(res, 200, { project_id: projectId, changes: project.snapshot?.changes || [] });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (parts.length === 4 && ['snapshot', 'snapshots'].includes(parts[3]) && req.method === 'POST') {
|
|
156
|
+
const body = parseJson(await readBody(req));
|
|
157
|
+
const validation = validateObserverSnapshot(body, { projectId });
|
|
158
|
+
if (!validation.ok) {
|
|
159
|
+
errorResponse(res, 400, 'invalid_snapshot', validation.errors.join(' '));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const result = appendObserverEvent(dataDir, body);
|
|
163
|
+
if (!result.accepted && result.duplicate) {
|
|
164
|
+
json(res, 200, { accepted: false, duplicate: true, event_id: body.event_id });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!result.accepted) {
|
|
168
|
+
const unregistered = result.errors.some((item) => /não registrado/.test(item));
|
|
169
|
+
errorResponse(res, unregistered ? 409 : 400, unregistered ? 'project_not_registered' : 'invalid_event', result.errors.join(' '));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
json(res, 201, { accepted: true, duplicate: false, event_id: body.event_id });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (res.headersSent) return;
|
|
179
|
+
const status = error?.code === 'payload_too_large' ? 413 : error?.code === 'invalid_json' ? 400 : 500;
|
|
180
|
+
errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await new Promise((resolve, reject) => {
|
|
185
|
+
const onError = (error) => {
|
|
186
|
+
server.off('listening', onListening);
|
|
187
|
+
reject(error);
|
|
188
|
+
};
|
|
189
|
+
const onListening = () => {
|
|
190
|
+
server.off('error', onError);
|
|
191
|
+
resolve();
|
|
192
|
+
};
|
|
193
|
+
server.once('error', onError);
|
|
194
|
+
server.once('listening', onListening);
|
|
195
|
+
server.listen(Number(port), host);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
server,
|
|
200
|
+
address: () => server.address(),
|
|
201
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
|
202
|
+
};
|
|
203
|
+
}
|