wendkeep 0.69.0 → 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.
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { allChangesState } from '../hooks/change-core.mjs';
5
+ import { readControl, readSessionRegistry } from '../hooks/obsidian-common.mjs';
6
+ import { runVaultHealth } from '../hooks/vault-health.mjs';
7
+ import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
8
+
9
+ export const OBSERVER_SCHEMA_VERSION = 1;
10
+ export const MAX_SNAPSHOT_BYTES = 32 * 1024;
11
+ const MAX_TEXT = 160;
12
+
13
+ function fail(message, code = 'WENDKEEP_OBSERVER_SNAPSHOT_INVALID') {
14
+ const error = new Error(message);
15
+ error.code = code;
16
+ return error;
17
+ }
18
+
19
+ function safeText(value, max = MAX_TEXT) {
20
+ return String(value ?? '')
21
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
22
+ .replace(/[A-Za-z]:[\\/][^\s"']*/g, '[REDACTED_PATH]')
23
+ .replace(/\\\\[^\s"']+/g, '[REDACTED_PATH]')
24
+ .replace(/\s+/g, ' ')
25
+ .trim()
26
+ .slice(0, max);
27
+ }
28
+
29
+ function isoNow(value) {
30
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
31
+ if (Number.isNaN(date.getTime())) throw fail('captured_at inválido.');
32
+ return date.toISOString();
33
+ }
34
+
35
+ function packageVersion() {
36
+ try {
37
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
38
+ } catch {
39
+ return '0.0.0';
40
+ }
41
+ }
42
+
43
+ function activeSessionSummary(vaultBase, control, registry) {
44
+ const entries = Object.entries(registry?.sessions || {})
45
+ .map(([sessionId, entry]) => ({ sessionId, entry }))
46
+ .sort((a, b) => String(b.entry?.last_seen || b.entry?.updated_at || '').localeCompare(String(a.entry?.last_seen || a.entry?.updated_at || '')));
47
+ const selected = entries.find(({ sessionId }) => sessionId === control?.session_id) || entries[0];
48
+ const entry = selected?.entry || {};
49
+ return {
50
+ status: safeText(control?.status || entry.status || 'inactive', 32),
51
+ session_id: safeText(control?.session_id || selected?.sessionId || '', 100),
52
+ provider: safeText(entry.provider || '', 32),
53
+ change_slug: safeText(entry.change_slug || '', 100),
54
+ last_seen: safeText(entry.last_seen || entry.updated_at || '', 40),
55
+ };
56
+ }
57
+
58
+ function healthSummary(vaultBase) {
59
+ try {
60
+ const health = runVaultHealth({ vaultBase });
61
+ return {
62
+ ok: health.ok === true,
63
+ status: safeText(health.memoryStatus || (health.ok ? 'healthy' : 'degraded'), 40),
64
+ failure_count: Array.isArray(health.failures) ? health.failures.length : 0,
65
+ warning_count: Array.isArray(health.warnings) ? health.warnings.length : 0,
66
+ registry_sessions: Number(health.metrics?.registrySessions || 0),
67
+ derived_notes: Number(health.metrics?.derivedNotes || 0),
68
+ };
69
+ } catch {
70
+ return {
71
+ ok: false,
72
+ status: 'unavailable',
73
+ failure_count: 1,
74
+ warning_count: 0,
75
+ registry_sessions: 0,
76
+ derived_notes: 0,
77
+ };
78
+ }
79
+ }
80
+
81
+ function hashEvent(snapshot) {
82
+ const canonical = JSON.stringify({ ...snapshot, event_id: undefined });
83
+ return `obs-${createHash('sha256').update(canonical).digest('hex').slice(0, 24)}`;
84
+ }
85
+
86
+ function hasForbiddenKey(value) {
87
+ if (!value || typeof value !== 'object') return false;
88
+ for (const [key, child] of Object.entries(value)) {
89
+ if (/(?:core|shared|digest|transcript|secret|token|prompt|raw|path|vault)/i.test(key)) return true;
90
+ if (hasForbiddenKey(child)) return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function hasAbsolutePath(value) {
96
+ if (typeof value === 'string') {
97
+ return /[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/]|(?:^|\s)\/(?:Users|home|mnt|var|tmp)\//.test(value);
98
+ }
99
+ if (!value || typeof value !== 'object') return false;
100
+ return Object.values(value).some(hasAbsolutePath);
101
+ }
102
+
103
+ export function validateObserverSnapshot(snapshot, { projectId = '' } = {}) {
104
+ const errors = [];
105
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
106
+ return { ok: false, errors: ['snapshot deve ser um objeto JSON.'] };
107
+ }
108
+ if (snapshot.schema_version !== OBSERVER_SCHEMA_VERSION) errors.push('schema_version incompatível.');
109
+ for (const key of ['event_id', 'project_id', 'project_name', 'wendkeep_version', 'captured_at']) {
110
+ if (typeof snapshot[key] !== 'string' || !snapshot[key].trim()) errors.push(`${key} ausente ou inválido.`);
111
+ }
112
+ if (projectId && snapshot.project_id !== projectId) errors.push('project_id não corresponde ao projeto registrado.');
113
+ if (!Array.isArray(snapshot.changes)) errors.push('changes deve ser uma lista.');
114
+ if (!snapshot.session || typeof snapshot.session !== 'object') errors.push('session ausente.');
115
+ if (!snapshot.health || typeof snapshot.health !== 'object') errors.push('health ausente.');
116
+ if (hasForbiddenKey(snapshot)) errors.push('snapshot contém campo não permitido.');
117
+ if (hasAbsolutePath(snapshot)) errors.push('snapshot contém caminho absoluto.');
118
+ const size = Buffer.byteLength(JSON.stringify(snapshot), 'utf8');
119
+ if (size > MAX_SNAPSHOT_BYTES) errors.push(`snapshot excede ${MAX_SNAPSHOT_BYTES} bytes.`);
120
+ return { ok: errors.length === 0, errors, size };
121
+ }
122
+
123
+ export function buildProjectSnapshot({ vaultBase, projectRoot = process.cwd(), now = new Date() } = {}) {
124
+ if (!vaultBase) throw fail('vaultBase é obrigatório.');
125
+ const project = readProjectForValidation(vaultBase);
126
+ if (!project.ok || !project.projectId) throw fail(project.errors?.join(' ') || 'PROJECT.json inválido.');
127
+ const control = readControl(vaultBase);
128
+ const registry = readSessionRegistry(vaultBase);
129
+ const changes = allChangesState(vaultBase).changes.map((change) => ({
130
+ slug: safeText(change.slug, 100),
131
+ current: change.current === true,
132
+ openTasks: Number(change.openCount || 0),
133
+ doneTasks: Number(change.doneCount || 0),
134
+ warning: safeText(change.warning || '', 120),
135
+ }));
136
+ const markerName = project.marker?.projectName || basename(projectRoot);
137
+ const snapshot = {
138
+ schema_version: OBSERVER_SCHEMA_VERSION,
139
+ event_id: '',
140
+ project_id: project.projectId,
141
+ projectId: project.projectId,
142
+ project_name: safeText(markerName || project.projectId, 100),
143
+ wendkeep_version: packageVersion(),
144
+ captured_at: isoNow(now),
145
+ session: activeSessionSummary(vaultBase, control, registry),
146
+ changes,
147
+ health: healthSummary(vaultBase),
148
+ };
149
+ snapshot.event_id = hashEvent(snapshot);
150
+ const validation = validateObserverSnapshot(snapshot, { projectId: project.projectId });
151
+ if (!validation.ok) throw fail(validation.errors.join(' '));
152
+ return snapshot;
153
+ }
@@ -0,0 +1,155 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { MAX_SNAPSHOT_BYTES, OBSERVER_SCHEMA_VERSION, validateObserverSnapshot } from './observer-snapshot.mjs';
4
+
5
+ export const OBSERVER_DATA_SCHEMA_VERSION = 1;
6
+ export const OBSERVER_EVENTS_FILE = 'EVENTS.jsonl';
7
+ export const OBSERVER_INDEX_FILE = 'INDEX.json';
8
+ export const OBSERVER_PROJECTS_FILE = 'PROJECTS.json';
9
+
10
+ function ensureDataDir(dataDir) {
11
+ if (!dataDir) throw new Error('dataDir é obrigatório.');
12
+ mkdirSync(dataDir, { recursive: true });
13
+ }
14
+
15
+ function atomicJson(path, value) {
16
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
17
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
18
+ renameSync(temp, path);
19
+ }
20
+
21
+ function readJson(path, fallback) {
22
+ if (!existsSync(path)) return fallback;
23
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
24
+ catch { return fallback; }
25
+ }
26
+
27
+ function projectIdValid(projectId) {
28
+ return typeof projectId === 'string'
29
+ && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/.test(projectId);
30
+ }
31
+
32
+ function registeredProjects(dataDir) {
33
+ const raw = readJson(join(dataDir, OBSERVER_PROJECTS_FILE), {
34
+ schema_version: OBSERVER_DATA_SCHEMA_VERSION,
35
+ projects: {},
36
+ });
37
+ return raw?.schema_version === OBSERVER_DATA_SCHEMA_VERSION && raw.projects && typeof raw.projects === 'object'
38
+ ? raw.projects
39
+ : {};
40
+ }
41
+
42
+ export function registerObserverProject(dataDir, {
43
+ projectId,
44
+ projectName = projectId,
45
+ wendkeepVersion = '',
46
+ registeredAt = new Date().toISOString(),
47
+ } = {}) {
48
+ ensureDataDir(dataDir);
49
+ if (!projectIdValid(projectId)) return { registered: false, errors: ['project_id inválido.'] };
50
+ const projects = registeredProjects(dataDir);
51
+ const project = {
52
+ projectId,
53
+ projectName: String(projectName || projectId).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120),
54
+ wendkeepVersion: String(wendkeepVersion || '').slice(0, 40),
55
+ registeredAt: String(registeredAt),
56
+ };
57
+ projects[projectId] = project;
58
+ atomicJson(join(dataDir, OBSERVER_PROJECTS_FILE), {
59
+ schema_version: OBSERVER_DATA_SCHEMA_VERSION,
60
+ projects,
61
+ });
62
+ return { registered: true, project };
63
+ }
64
+
65
+ export function listRegisteredObserverProjects(dataDir) {
66
+ return Object.values(registeredProjects(dataDir)).sort((a, b) => a.projectId.localeCompare(b.projectId));
67
+ }
68
+
69
+ function readEvents(dataDir) {
70
+ const path = join(dataDir, OBSERVER_EVENTS_FILE);
71
+ if (!existsSync(path)) return [];
72
+ const events = [];
73
+ const lines = readFileSync(path, 'utf8').replace(/\r\n/g, '\n').split('\n').filter((line) => line.trim());
74
+ for (const line of lines) {
75
+ try {
76
+ const event = JSON.parse(line);
77
+ if (validateObserverSnapshot(event).ok) events.push(event);
78
+ } catch { /* corrupt lines never become part of the derived index */ }
79
+ }
80
+ return events;
81
+ }
82
+
83
+ function newer(left, right) {
84
+ const leftTime = Date.parse(left?.captured_at || '') || 0;
85
+ const rightTime = Date.parse(right?.captured_at || '') || 0;
86
+ return leftTime > rightTime || (leftTime === rightTime && String(left?.event_id).localeCompare(String(right?.event_id)) > 0);
87
+ }
88
+
89
+ export function rebuildObserverIndex(dataDir) {
90
+ ensureDataDir(dataDir);
91
+ const byProject = new Map();
92
+ for (const event of readEvents(dataDir)) {
93
+ const current = byProject.get(event.project_id);
94
+ if (!current || newer(event, current.snapshot)) {
95
+ byProject.set(event.project_id, {
96
+ projectId: event.project_id,
97
+ projectName: event.project_name,
98
+ latestEventId: event.event_id,
99
+ capturedAt: event.captured_at,
100
+ snapshot: event,
101
+ eventCount: 0,
102
+ });
103
+ }
104
+ }
105
+ for (const event of readEvents(dataDir)) {
106
+ const item = byProject.get(event.project_id);
107
+ if (item) item.eventCount += 1;
108
+ }
109
+ const index = {
110
+ schema_version: OBSERVER_DATA_SCHEMA_VERSION,
111
+ generated_at: new Date().toISOString(),
112
+ projects: [...byProject.values()].sort((a, b) => a.projectId.localeCompare(b.projectId)),
113
+ };
114
+ atomicJson(join(dataDir, OBSERVER_INDEX_FILE), index);
115
+ return index;
116
+ }
117
+
118
+ export function readObserverIndex(dataDir) {
119
+ ensureDataDir(dataDir);
120
+ const path = join(dataDir, OBSERVER_INDEX_FILE);
121
+ const index = readJson(path, null);
122
+ if (index?.schema_version === OBSERVER_DATA_SCHEMA_VERSION && Array.isArray(index.projects)) return index;
123
+ return rebuildObserverIndex(dataDir);
124
+ }
125
+
126
+ export function appendObserverEvent(dataDir, event) {
127
+ ensureDataDir(dataDir);
128
+ const validation = validateObserverSnapshot(event);
129
+ if (!validation.ok || validation.size > MAX_SNAPSHOT_BYTES) {
130
+ return { accepted: false, errors: validation.errors || ['snapshot inválido.'] };
131
+ }
132
+ const projects = registeredProjects(dataDir);
133
+ if (!projects[event.project_id]) {
134
+ return { accepted: false, errors: [`project_id não registrado: ${event.project_id}`] };
135
+ }
136
+ const eventsPath = join(dataDir, OBSERVER_EVENTS_FILE);
137
+ const existing = readEvents(dataDir).find((item) => item.event_id === event.event_id);
138
+ if (existing) {
139
+ if (JSON.stringify(existing) !== JSON.stringify(event)) {
140
+ return { accepted: false, errors: [`event_id reutilizado com payload diferente: ${event.event_id}`] };
141
+ }
142
+ return { accepted: false, duplicate: true, event_id: event.event_id, index: readObserverIndex(dataDir) };
143
+ }
144
+ appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, 'utf8');
145
+ return {
146
+ accepted: true,
147
+ duplicate: false,
148
+ event_id: event.event_id,
149
+ index: rebuildObserverIndex(dataDir),
150
+ };
151
+ }
152
+
153
+ export function getObserverProject(dataDir, projectId) {
154
+ return readObserverIndex(dataDir).projects.find((project) => project.projectId === projectId) || null;
155
+ }