wendkeep 0.71.1 → 0.72.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 +28 -0
- package/README.en.md +6 -2
- package/README.md +6 -2
- package/docs/en/commands/observer.md +54 -35
- package/docs/pt-BR/commands/observer.md +56 -37
- package/hooks/observer-publish.mjs +1 -0
- package/package.json +2 -2
- package/packages/integrations/src/host-hooks.mjs +2 -0
- package/schema/observer/001-authority.sql +107 -0
- package/schema/observer/002-usage.sql +72 -0
- package/schema/observer/003-transcripts.sql +21 -0
- package/src/observer-memory-publish.mjs +2 -1
- package/src/observer-publish.mjs +15 -13
- package/src/observer-server.mjs +223 -40
- package/src/observer-sql-migrate.mjs +335 -0
- package/src/observer-sql-publish.mjs +398 -0
- package/src/observer-sql-store.mjs +542 -0
- package/src/observer-transcript-store.mjs +49 -0
- package/src/observer.mjs +23 -5
- package/web/observer/app.mjs +248 -1
- package/web/observer/index.html +1 -0
- package/web/observer/styles.css +34 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { gzipSync } from 'node:zlib';
|
|
13
|
+
import { parseTranscriptContent } from '../packages/integrations/src/transcripts.mjs';
|
|
14
|
+
import { parseSessionCost, } from './cost.mjs';
|
|
15
|
+
import { buildSessionIdentityMap, listMigrationDocuments, parseFrontmatter, sessionEvents } from './observer-sql-migrate.mjs';
|
|
16
|
+
|
|
17
|
+
export const SQL_OUTBOX_REL = '.brain/observer-sql-outbox';
|
|
18
|
+
export const SQL_STATE_REL = '.brain/observer-sql-state.json';
|
|
19
|
+
const SQL_SCHEMA_VERSION = 1;
|
|
20
|
+
export const SQL_EVENT_BATCH_SIZE = 64;
|
|
21
|
+
export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
23
|
+
|
|
24
|
+
function text(value, fallback = '') { return String(value ?? fallback); }
|
|
25
|
+
function hash(value) { return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex'); }
|
|
26
|
+
function eventId(kind, projectId, seed) { return `sql-${kind}-${hash(`${projectId}:${seed}`).slice(0, 24)}`; }
|
|
27
|
+
function isoNow(value) {
|
|
28
|
+
const date = value instanceof Date ? value : new Date(value || Date.now());
|
|
29
|
+
if (Number.isNaN(date.getTime())) throw new Error('occurred_at inválido.');
|
|
30
|
+
return date.toISOString();
|
|
31
|
+
}
|
|
32
|
+
function readJson(path, fallback) {
|
|
33
|
+
if (!existsSync(path)) return fallback;
|
|
34
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
|
35
|
+
}
|
|
36
|
+
function atomicJson(path, value) {
|
|
37
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
38
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
39
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
40
|
+
renameSync(temp, path);
|
|
41
|
+
}
|
|
42
|
+
function statePath(vaultBase) { return join(vaultBase, SQL_STATE_REL); }
|
|
43
|
+
function readState(vaultBase) {
|
|
44
|
+
return readJson(statePath(vaultBase), { schema_version: SQL_SCHEMA_VERSION, files: {}, transcripts: {} });
|
|
45
|
+
}
|
|
46
|
+
function outboxDir(vaultBase) { return join(vaultBase, SQL_OUTBOX_REL); }
|
|
47
|
+
function outboxPath(vaultBase, batch) { return join(outboxDir(vaultBase), `sql-${hash(JSON.stringify(batch.events)).slice(0, 24)}.json`); }
|
|
48
|
+
|
|
49
|
+
function normalizePath(value) { return String(value || '').replaceAll('\\', '/'); }
|
|
50
|
+
function readRegistry(vaultBase) {
|
|
51
|
+
return readJson(join(vaultBase, '.brain', 'SESSION_REGISTRY.json'), { sessions: {} });
|
|
52
|
+
}
|
|
53
|
+
function registryEntry(vaultBase, logicalPath, sessionId) {
|
|
54
|
+
const sessions = readRegistry(vaultBase).sessions || {};
|
|
55
|
+
return Object.entries(sessions).find(([id, entry]) => id === sessionId || normalizePath(entry?.session_file) === logicalPath)?.[1] || {};
|
|
56
|
+
}
|
|
57
|
+
function transcriptIdFromPath(path) { return basename(String(path || '')).replace(/\.jsonl?$/i, '') || ''; }
|
|
58
|
+
function modelProvider(provider, model) {
|
|
59
|
+
const clean = String(provider || '').toLowerCase();
|
|
60
|
+
if (clean.includes('claude') || clean.includes('anthropic') || String(model).startsWith('claude-')) return 'anthropic';
|
|
61
|
+
if (clean.includes('codex') || clean.includes('openai') || String(model).startsWith('gpt-')) return 'openai';
|
|
62
|
+
return clean;
|
|
63
|
+
}
|
|
64
|
+
function tokenPayload(usage = {}) {
|
|
65
|
+
return {
|
|
66
|
+
input: Number(usage.input) || 0,
|
|
67
|
+
cache_write: Number(usage.cacheWrite) || 0,
|
|
68
|
+
cache_read: Number(usage.cached) || 0,
|
|
69
|
+
output: Number(usage.output) || 0,
|
|
70
|
+
reasoning: Number(usage.reasoning) || 0,
|
|
71
|
+
total: Number(usage.total) || 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function documentEvent({ projectId, logicalPath, content, metadata, revision, occurredAt }) {
|
|
76
|
+
const contentHash = hash(content);
|
|
77
|
+
return {
|
|
78
|
+
schema_version: 1,
|
|
79
|
+
event_id: eventId('document', projectId, `${logicalPath}:${revision}:${contentHash}`),
|
|
80
|
+
kind: 'document.upsert',
|
|
81
|
+
project_id: projectId,
|
|
82
|
+
occurred_at: occurredAt,
|
|
83
|
+
payload: {
|
|
84
|
+
logical_path: logicalPath,
|
|
85
|
+
entity_type: logicalPath.startsWith('02-Sessões/') ? 'session'
|
|
86
|
+
: logicalPath.startsWith('04-Decisões/') ? 'decision'
|
|
87
|
+
: logicalPath.startsWith('05-Bugs/') ? 'bug'
|
|
88
|
+
: logicalPath.startsWith('06-Aprendizados/') ? 'learning'
|
|
89
|
+
: logicalPath.startsWith('07-Specs/') ? 'spec'
|
|
90
|
+
: logicalPath.startsWith('08-Mudanças/') ? 'change' : 'memory',
|
|
91
|
+
title: basename(logicalPath).replace(/\.md$/i, ''),
|
|
92
|
+
content,
|
|
93
|
+
content_hash: contentHash,
|
|
94
|
+
revision,
|
|
95
|
+
metadata,
|
|
96
|
+
source_session_id: text(metadata?.session_id),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function agentEvent({ projectId, sessionId, agentId, parentAgentId = '', role = 'main', provider = '', model = '', input = {}, occurredAt }) {
|
|
102
|
+
const fingerprint = hash({ agentId, role, provider, model, input: input.agent_id || input.agent_transcript_path || '' }).slice(0, 24);
|
|
103
|
+
return {
|
|
104
|
+
schema_version: 1,
|
|
105
|
+
event_id: eventId('agent', projectId, `${agentId}:${fingerprint}`),
|
|
106
|
+
kind: 'agent.upsert',
|
|
107
|
+
project_id: projectId,
|
|
108
|
+
occurred_at: occurredAt,
|
|
109
|
+
payload: {
|
|
110
|
+
agent_id: agentId,
|
|
111
|
+
session_id: sessionId,
|
|
112
|
+
parent_agent_id: parentAgentId || null,
|
|
113
|
+
role,
|
|
114
|
+
agent_name: text(input.agent_name || input.agentName || provider),
|
|
115
|
+
agent_type: text(input.agent_type || input.agentType || role),
|
|
116
|
+
workflow: text(input.workflow),
|
|
117
|
+
status: text(input.status, 'running'),
|
|
118
|
+
model,
|
|
119
|
+
effort: text(input.effort || input.nivel_pensamento),
|
|
120
|
+
started_at: input.started_at || null,
|
|
121
|
+
ended_at: input.ended_at || null,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelFallback, transcriptId, content, occurredAt }) {
|
|
127
|
+
let parsed;
|
|
128
|
+
try { parsed = parseTranscriptContent(content); } catch { return []; }
|
|
129
|
+
return (parsed.turns || []).flatMap((turn, index) => {
|
|
130
|
+
const prompt = (turn.userPrompts || []).join('\n\n');
|
|
131
|
+
const response = (turn.assistantMessages || []).join('\n\n');
|
|
132
|
+
const tokens = tokenPayload(turn.usage);
|
|
133
|
+
if (!prompt && !response && !tokens.total) return [];
|
|
134
|
+
const model = text(turn.model || parsed.model || modelFallback, '?');
|
|
135
|
+
const callId = eventId('call', projectId, `${transcriptId}:${agentId}:${turn.turnId || index + 1}:${hash(content).slice(0, 16)}`);
|
|
136
|
+
return [{
|
|
137
|
+
schema_version: 1,
|
|
138
|
+
event_id: eventId('call-event', projectId, callId),
|
|
139
|
+
kind: 'llm_call',
|
|
140
|
+
project_id: projectId,
|
|
141
|
+
occurred_at: text(turn.timestamp, occurredAt),
|
|
142
|
+
payload: {
|
|
143
|
+
call_id: callId,
|
|
144
|
+
session_id: sessionId,
|
|
145
|
+
agent_id: agentId,
|
|
146
|
+
role,
|
|
147
|
+
provider,
|
|
148
|
+
model_provider: modelProvider(provider, model),
|
|
149
|
+
model,
|
|
150
|
+
effort: '',
|
|
151
|
+
sequence: index + 1,
|
|
152
|
+
occurred_at: text(turn.timestamp, occurredAt),
|
|
153
|
+
tokens,
|
|
154
|
+
cost_usd: 0,
|
|
155
|
+
cost_status: 'unknown',
|
|
156
|
+
transcript_id: transcriptId,
|
|
157
|
+
prompt_text: prompt,
|
|
158
|
+
response_text: response,
|
|
159
|
+
status: turn.status === 'aborted' ? 'aborted' : 'complete',
|
|
160
|
+
metadata: { tools: turn.tools || [], source: 'transcript-parser' },
|
|
161
|
+
},
|
|
162
|
+
}];
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sourceCandidates({ vaultBase, logicalPath, fm, input }) {
|
|
167
|
+
const sessionId = text(fm.session_id || input?.session_id || input?.sessionId);
|
|
168
|
+
const registry = registryEntry(vaultBase, logicalPath, sessionId);
|
|
169
|
+
const candidates = [];
|
|
170
|
+
const add = (path, id = '', role = 'main', agentInput = {}) => {
|
|
171
|
+
if (!path || !existsSync(path)) return;
|
|
172
|
+
const transcriptId = text(id) || transcriptIdFromPath(path);
|
|
173
|
+
if (transcriptId && !candidates.some((item) => item.transcriptId === transcriptId)) candidates.push({ path, transcriptId, role, agentInput });
|
|
174
|
+
};
|
|
175
|
+
add(input?.transcript_path || input?.transcriptPath || '', input?.transcript_id || input?.transcriptId, 'main', input || {});
|
|
176
|
+
add(input?.agent_transcript_path || input?.agentTranscriptPath || '', input?.agent_transcript_id || input?.agentTranscriptId, 'subagent', input || {});
|
|
177
|
+
add(fm.transcript_path || fm.transcriptPath || '', fm.observability_transcript_id);
|
|
178
|
+
add(registry.transcript_path, registry.transcript_id);
|
|
179
|
+
for (const path of registry.transcript_paths || []) add(path, transcriptIdFromPath(path));
|
|
180
|
+
return candidates;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now }) {
|
|
184
|
+
const content = readFileSync(source.path, 'utf8');
|
|
185
|
+
const agentId = source.role === 'subagent'
|
|
186
|
+
? `${projectId}:${sessionId}:subagent:${hash(source.path).slice(0, 16)}`
|
|
187
|
+
: mainAgentId;
|
|
188
|
+
const agent = source.role === 'subagent'
|
|
189
|
+
? agentEvent({ projectId, sessionId, agentId, parentAgentId: mainAgentId, role: 'subagent', provider, model, input: source.agentInput, occurredAt: now })
|
|
190
|
+
: null;
|
|
191
|
+
const fingerprint = hash(content);
|
|
192
|
+
const transcript = {
|
|
193
|
+
schema_version: 1,
|
|
194
|
+
event_id: eventId('transcript', projectId, `${source.transcriptId}:${fingerprint}`),
|
|
195
|
+
kind: 'transcript.upsert',
|
|
196
|
+
project_id: projectId,
|
|
197
|
+
occurred_at: now,
|
|
198
|
+
payload: {
|
|
199
|
+
transcript_id: source.transcriptId,
|
|
200
|
+
session_id: sessionId,
|
|
201
|
+
agent_id: agentId,
|
|
202
|
+
coverage: 'complete',
|
|
203
|
+
content,
|
|
204
|
+
source: 'hook-transcript',
|
|
205
|
+
metadata: { original_path: source.path.replaceAll('\\', '/') },
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const calls = transcriptCalls({ projectId, sessionId, agentId, role: source.role, provider, modelFallback: model, transcriptId: source.transcriptId, content, occurredAt: now });
|
|
209
|
+
return { events: [agent, transcript, ...calls].filter(Boolean), fingerprint, transcriptId: source.transcriptId };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function dedupeEvents(events) {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
return events.filter((event) => {
|
|
215
|
+
if (seen.has(event.event_id)) return false;
|
|
216
|
+
seen.add(event.event_id);
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {} } = {}) {
|
|
222
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
223
|
+
const occurredAt = isoNow(now);
|
|
224
|
+
const nextState = { schema_version: SQL_SCHEMA_VERSION, files: { ...(state.files || {}) }, transcripts: { ...(state.transcripts || {}) } };
|
|
225
|
+
const events = [];
|
|
226
|
+
const sessionContexts = [];
|
|
227
|
+
const files = listMigrationDocuments(vaultBase);
|
|
228
|
+
const sessionFiles = files.flatMap((file) => {
|
|
229
|
+
if (!file.logicalPath.startsWith('02-Sessões/')) return [];
|
|
230
|
+
const content = readFileSync(file.absolute, 'utf8');
|
|
231
|
+
const fm = parseFrontmatter(content);
|
|
232
|
+
return fm.type === 'session' ? [{ file, content, fm }] : [];
|
|
233
|
+
});
|
|
234
|
+
const sessionIdentity = buildSessionIdentityMap({ projectId, sessionFiles });
|
|
235
|
+
let changed = 0;
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
const content = readFileSync(file.absolute, 'utf8');
|
|
238
|
+
const contentHash = hash(content);
|
|
239
|
+
const previous = state.files?.[file.logicalPath];
|
|
240
|
+
const remote = remoteDocuments?.[file.logicalPath];
|
|
241
|
+
const baseRevision = Math.max(Number(previous?.revision || 0), Number(remote?.revision || 0));
|
|
242
|
+
const unchanged = previous?.content_hash === contentHash || remote?.content_hash === contentHash;
|
|
243
|
+
const revision = baseRevision + (unchanged ? 0 : 1);
|
|
244
|
+
nextState.files[file.logicalPath] = { content_hash: contentHash, revision: revision || 1 };
|
|
245
|
+
const fm = parseFrontmatter(content);
|
|
246
|
+
if (fm.type === 'session') sessionContexts.push({ file, content, fm, contentHash, revision: revision || 1, sessionId: sessionIdentity.get(file.logicalPath) });
|
|
247
|
+
if (previous?.content_hash === contentHash) continue;
|
|
248
|
+
changed += 1;
|
|
249
|
+
events.push(documentEvent({ projectId, logicalPath: file.logicalPath, content, metadata: fm, revision: revision || 1, occurredAt }));
|
|
250
|
+
if (fm.type === 'session') {
|
|
251
|
+
const cost = parseSessionCost(content) || { model: '?', mainCost: 0, subCost: 0, tokens: 0, subTokens: 0, ledger: [] };
|
|
252
|
+
events.push(...sessionEvents({ projectId, logicalPath: file.logicalPath, content, cost, revision: revision || 1, sessionId: sessionIdentity.get(file.logicalPath) }).events);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const context of sessionContexts) {
|
|
257
|
+
const sessionId = text(context.sessionId || context.fm.session_id || `historical:${hash(`${projectId}:${context.file.logicalPath}`).slice(0, 20)}`);
|
|
258
|
+
const mainAgentId = `${projectId}:${sessionId}:main`;
|
|
259
|
+
const provider = text(context.fm.provider);
|
|
260
|
+
const model = text(context.fm.modelo || context.fm.custo_modelo_label, '?');
|
|
261
|
+
const sources = sourceCandidates({ vaultBase, logicalPath: context.file.logicalPath, fm: context.fm, input })
|
|
262
|
+
.map((source) => ({ ...source, role: source.role || 'main' }));
|
|
263
|
+
for (const source of sources) {
|
|
264
|
+
const content = readFileSync(source.path, 'utf8');
|
|
265
|
+
const fingerprint = hash(content);
|
|
266
|
+
if (state.transcripts?.[source.transcriptId]?.content_hash === fingerprint) continue;
|
|
267
|
+
const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt });
|
|
268
|
+
nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: 'complete' };
|
|
269
|
+
const summaryId = complete.transcriptId;
|
|
270
|
+
for (const event of complete.events) {
|
|
271
|
+
if (event.kind === 'agent.upsert' && event.payload.agent_id !== mainAgentId) events.push(event);
|
|
272
|
+
else if (event.kind !== 'agent.upsert') events.push(event);
|
|
273
|
+
}
|
|
274
|
+
// A session document may have emitted a summary-only placeholder. The
|
|
275
|
+
// complete event follows it and is intentionally idempotent by content hash.
|
|
276
|
+
void summaryId;
|
|
277
|
+
}
|
|
278
|
+
if (sources.length === 0 && context.fm.observability_transcript_id) {
|
|
279
|
+
nextState.transcripts[context.fm.observability_transcript_id] ||= { content_hash: '', coverage: 'summary_only' };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { events: dedupeEvents(events), nextState, scanned: Object.keys(nextState.files).length, changed };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function queueOutbox(vaultBase, batch) {
|
|
286
|
+
mkdirSync(outboxDir(vaultBase), { recursive: true });
|
|
287
|
+
const path = outboxPath(vaultBase, batch);
|
|
288
|
+
if (!existsSync(path)) atomicJson(path, batch);
|
|
289
|
+
return path;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function listSqlOutbox(vaultBase) {
|
|
293
|
+
const dir = outboxDir(vaultBase);
|
|
294
|
+
if (!existsSync(dir)) return [];
|
|
295
|
+
return readdirSync(dir).filter((name) => /^sql-[a-f0-9]{24}\.json$/.test(name)).sort().flatMap((name) => {
|
|
296
|
+
try { return [{ path: join(dir, name), ...JSON.parse(readFileSync(join(dir, name), 'utf8')) }]; } catch { return []; }
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fetch }) {
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
303
|
+
const wireBody = gzipSync(Buffer.from(JSON.stringify({ events }), 'utf8'));
|
|
304
|
+
try {
|
|
305
|
+
const response = await fetchImpl(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(projectId)}/ingest`, {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'content-type': 'application/json', 'content-encoding': 'gzip', accept: 'application/json' },
|
|
308
|
+
body: wireBody,
|
|
309
|
+
signal: controller.signal,
|
|
310
|
+
});
|
|
311
|
+
const responseBody = await response.json().catch(() => ({}));
|
|
312
|
+
if (!response.ok || responseBody.conflicts > 0 || responseBody.rejected > 0) throw new Error(`Observer ingest respondeu HTTP ${response.status}.`);
|
|
313
|
+
return responseBody;
|
|
314
|
+
} finally { clearTimeout(timer); }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function readRemoteDocuments({ url, projectId, fetchImpl = globalThis.fetch }) {
|
|
318
|
+
if (!url) return {};
|
|
319
|
+
try {
|
|
320
|
+
const response = await fetchImpl(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(projectId)}/memory/tree`, {
|
|
321
|
+
headers: { accept: 'application/json' },
|
|
322
|
+
});
|
|
323
|
+
if (!response.ok) return {};
|
|
324
|
+
const body = await response.json().catch(() => ({}));
|
|
325
|
+
return Object.fromEntries((body.documents || []).map((item) => [item.logical_path, item]));
|
|
326
|
+
} catch {
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fetch }) {
|
|
332
|
+
const aggregate = { accepted: 0, rejected: 0, conflicts: 0, stale: 0, duplicates: 0 };
|
|
333
|
+
let chunk = [];
|
|
334
|
+
let chunkBytes = 0;
|
|
335
|
+
const send = async (items) => {
|
|
336
|
+
if (!items.length) return;
|
|
337
|
+
const response = await postSqlChunk({
|
|
338
|
+
url,
|
|
339
|
+
projectId,
|
|
340
|
+
events: items,
|
|
341
|
+
fetchImpl,
|
|
342
|
+
});
|
|
343
|
+
for (const key of Object.keys(aggregate)) aggregate[key] += Number(response?.[key]) || 0;
|
|
344
|
+
};
|
|
345
|
+
for (const event of events) {
|
|
346
|
+
const eventBytes = Buffer.byteLength(JSON.stringify(event));
|
|
347
|
+
const exceedsCount = chunk.length >= SQL_EVENT_BATCH_SIZE;
|
|
348
|
+
const exceedsBytes = chunk.length > 0 && chunkBytes + eventBytes > SQL_EVENT_BATCH_BYTES;
|
|
349
|
+
if (exceedsCount || exceedsBytes) {
|
|
350
|
+
await send(chunk);
|
|
351
|
+
chunk = [];
|
|
352
|
+
chunkBytes = 0;
|
|
353
|
+
}
|
|
354
|
+
chunk.push(event);
|
|
355
|
+
chunkBytes += eventBytes;
|
|
356
|
+
}
|
|
357
|
+
await send(chunk);
|
|
358
|
+
return aggregate;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl = globalThis.fetch } = {}) {
|
|
362
|
+
const pending = listSqlOutbox(vaultBase);
|
|
363
|
+
if (!url) return { attempted: 0, confirmed: 0, pending: pending.length };
|
|
364
|
+
let attempted = 0;
|
|
365
|
+
let confirmed = 0;
|
|
366
|
+
for (const batch of pending) {
|
|
367
|
+
attempted += 1;
|
|
368
|
+
try {
|
|
369
|
+
await postSqlBatch({ url, projectId, events: batch.events, fetchImpl });
|
|
370
|
+
unlinkSync(batch.path);
|
|
371
|
+
confirmed += 1;
|
|
372
|
+
} catch { break; }
|
|
373
|
+
}
|
|
374
|
+
return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch } = {}) {
|
|
378
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
379
|
+
const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl });
|
|
380
|
+
const state = readState(vaultBase);
|
|
381
|
+
const remoteDocuments = Object.keys(state.files || {}).length === 0
|
|
382
|
+
? await readRemoteDocuments({ url, projectId, fetchImpl })
|
|
383
|
+
: {};
|
|
384
|
+
const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments });
|
|
385
|
+
atomicJson(statePath(vaultBase), batch.nextState);
|
|
386
|
+
if (!batch.events.length) return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
|
|
387
|
+
if (!url) {
|
|
388
|
+
queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
|
|
389
|
+
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0 };
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const response = await postSqlBatch({ url, projectId, events: batch.events, fetchImpl });
|
|
393
|
+
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, response };
|
|
394
|
+
} catch (error) {
|
|
395
|
+
queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
|
|
396
|
+
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0, error: error.message };
|
|
397
|
+
}
|
|
398
|
+
}
|