wendkeep 0.74.0 → 0.75.1
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 +47 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/en/commands/observer.md +29 -13
- package/docs/pt-BR/commands/observer.md +27 -11
- package/package.json +1 -1
- package/packages/cli/src/index.mjs +1 -1
- package/schema/observer/005-project-scoped-identities.sql +217 -0
- package/src/change.mjs +41 -1
- package/src/doctor.mjs +5 -0
- package/src/init.mjs +2 -2
- package/src/note.mjs +8 -1
- package/src/observer-publish.mjs +9 -34
- package/src/observer-server.mjs +38 -63
- package/src/observer-sql-migrate.mjs +1 -1
- package/src/observer-sql-publish.mjs +392 -18
- package/src/observer-sql-store.mjs +108 -28
- package/src/observer-store.mjs +15 -3
- package/src/observer.mjs +106 -14
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
readdirSync,
|
|
6
6
|
readFileSync,
|
|
7
7
|
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
8
10
|
unlinkSync,
|
|
9
11
|
writeFileSync,
|
|
10
12
|
} from 'node:fs';
|
|
@@ -18,11 +20,13 @@ import { sanitizeObserverContent, sanitizeObserverMetadata } from './observer-pr
|
|
|
18
20
|
|
|
19
21
|
export const SQL_OUTBOX_REL = '.brain/observer-sql-outbox';
|
|
20
22
|
export const SQL_STATE_REL = '.brain/observer-sql-state.json';
|
|
23
|
+
export const SQL_PUBLISHER_LEASE_REL = '.brain/observer-sql-publisher.lock';
|
|
21
24
|
const SQL_SCHEMA_VERSION = 1;
|
|
22
25
|
export const SQL_EVENT_BATCH_SIZE = 64;
|
|
23
26
|
export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
24
27
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
25
28
|
const MAX_REQUEST_TIMEOUT_MS = 120000;
|
|
29
|
+
export const SQL_LEASE_STALE_MS = MAX_REQUEST_TIMEOUT_MS + 30000;
|
|
26
30
|
const REQUEST_TIMEOUT_BYTES_STEP = 1024 * 1024;
|
|
27
31
|
const CAPTURE_LEVELS = new Set(['metadata', 'messages', 'full-transcript']);
|
|
28
32
|
|
|
@@ -158,7 +162,11 @@ function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelF
|
|
|
158
162
|
const tokens = tokenPayload(turn.usage);
|
|
159
163
|
if (!prompt && !response && !tokens.total) return [];
|
|
160
164
|
const model = text(turn.model || parsed.model || modelFallback, '?');
|
|
161
|
-
const
|
|
165
|
+
const stableTurn = turn.turnId || index + 1;
|
|
166
|
+
const turnRevision = turn.status === 'complete'
|
|
167
|
+
? 'complete'
|
|
168
|
+
: hash({ prompt, response, tokens, status: turn.status }).slice(0, 16);
|
|
169
|
+
const callId = eventId('call', projectId, `${transcriptId}:${agentId}:${stableTurn}:${turnRevision}`);
|
|
162
170
|
return [{
|
|
163
171
|
schema_version: 1,
|
|
164
172
|
event_id: eventId('call-event', projectId, callId),
|
|
@@ -255,7 +263,7 @@ function dedupeEvents(events) {
|
|
|
255
263
|
});
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
|
|
266
|
+
export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
|
|
259
267
|
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
260
268
|
const occurredAt = isoNow(now);
|
|
261
269
|
const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
|
|
@@ -282,7 +290,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
|
|
|
282
290
|
nextState.files[file.logicalPath] = { content_hash: contentHash, revision: revision || 1 };
|
|
283
291
|
const fm = parseFrontmatter(content);
|
|
284
292
|
if (fm.type === 'session') sessionContexts.push({ file, content, fm, contentHash, revision: revision || 1, sessionId: sessionIdentity.get(file.logicalPath) });
|
|
285
|
-
if (previous?.content_hash === contentHash) continue;
|
|
293
|
+
if (!forceFull && previous?.content_hash === contentHash) continue;
|
|
286
294
|
changed += 1;
|
|
287
295
|
events.push(documentEvent({ projectId, logicalPath: file.logicalPath, content, metadata: fm, revision: revision || 1, occurredAt }));
|
|
288
296
|
if (fm.type === 'session') {
|
|
@@ -302,7 +310,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
|
|
|
302
310
|
const content = readFileSync(source.path, 'utf8');
|
|
303
311
|
const fingerprint = hash(content);
|
|
304
312
|
const previousTranscript = state.transcripts?.[source.transcriptId];
|
|
305
|
-
if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
|
|
313
|
+
if (!forceFull && previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
|
|
306
314
|
const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt, captureLevel: resolvedCaptureLevel });
|
|
307
315
|
nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: resolvedCaptureLevel };
|
|
308
316
|
const summaryId = complete.transcriptId;
|
|
@@ -321,6 +329,136 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
|
|
|
321
329
|
return { events: dedupeEvents(events), nextState, scanned: Object.keys(nextState.files).length, changed };
|
|
322
330
|
}
|
|
323
331
|
|
|
332
|
+
function hookEventName(input = {}) {
|
|
333
|
+
return text(input.hook_event_name || input.hookEventName || input.event_name || input.event).toLowerCase();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function incrementalSessionContext(vaultBase, input = {}) {
|
|
337
|
+
const registry = readRegistry(vaultBase);
|
|
338
|
+
const requestedId = text(
|
|
339
|
+
input.session_id || input.sessionId || input.thread_id || input.threadId
|
|
340
|
+
|| input.conversation_id || input.conversationId,
|
|
341
|
+
);
|
|
342
|
+
const pair = Object.entries(registry.sessions || {}).find(([sessionId, entry]) => (
|
|
343
|
+
(requestedId && sessionId === requestedId)
|
|
344
|
+
|| (input.session_file && normalizePath(entry?.session_file) === normalizePath(input.session_file))
|
|
345
|
+
));
|
|
346
|
+
if (!pair?.[1]?.session_file) return null;
|
|
347
|
+
const logicalPath = normalizePath(pair[1].session_file);
|
|
348
|
+
if (!logicalPath || logicalPath.startsWith('/') || logicalPath.includes('..')) return null;
|
|
349
|
+
const absolute = join(vaultBase, logicalPath);
|
|
350
|
+
if (!existsSync(absolute)) return null;
|
|
351
|
+
const content = readFileSync(absolute, 'utf8');
|
|
352
|
+
return {
|
|
353
|
+
sessionId: pair[0],
|
|
354
|
+
entry: pair[1],
|
|
355
|
+
file: { logicalPath, absolute },
|
|
356
|
+
content,
|
|
357
|
+
fm: parseFrontmatter(content),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Build only the session/subagent evidence named by the current hook payload. */
|
|
362
|
+
export function buildObserverSqlIncrementalBatch({
|
|
363
|
+
vaultBase,
|
|
364
|
+
projectId,
|
|
365
|
+
input = {},
|
|
366
|
+
now = new Date(),
|
|
367
|
+
state = readState(vaultBase),
|
|
368
|
+
captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
|
|
369
|
+
} = {}) {
|
|
370
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
371
|
+
const eventName = hookEventName(input);
|
|
372
|
+
const nextState = {
|
|
373
|
+
schema_version: SQL_SCHEMA_VERSION,
|
|
374
|
+
files: { ...(state.files || {}) },
|
|
375
|
+
transcripts: { ...(state.transcripts || {}) },
|
|
376
|
+
};
|
|
377
|
+
if (eventName.includes('sessionstart')) {
|
|
378
|
+
return { events: [], nextState, scanned: 0, changed: 0, scope: 'drain-only' };
|
|
379
|
+
}
|
|
380
|
+
const context = incrementalSessionContext(vaultBase, input);
|
|
381
|
+
if (!context) return { events: [], nextState, scanned: 0, changed: 0, scope: 'drain-only' };
|
|
382
|
+
|
|
383
|
+
const occurredAt = isoNow(now);
|
|
384
|
+
const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
|
|
385
|
+
const events = [];
|
|
386
|
+
let changed = 0;
|
|
387
|
+
const safeContent = sanitizeObserverContent(context.content);
|
|
388
|
+
const contentHash = hash(safeContent);
|
|
389
|
+
const previous = state.files?.[context.file.logicalPath];
|
|
390
|
+
const revision = Math.max(1, Number(previous?.revision || 0) + (previous?.content_hash === contentHash ? 0 : 1));
|
|
391
|
+
nextState.files[context.file.logicalPath] = { content_hash: contentHash, revision };
|
|
392
|
+
if (previous?.content_hash !== contentHash) {
|
|
393
|
+
changed += 1;
|
|
394
|
+
events.push(documentEvent({
|
|
395
|
+
projectId,
|
|
396
|
+
logicalPath: context.file.logicalPath,
|
|
397
|
+
content: context.content,
|
|
398
|
+
metadata: context.fm,
|
|
399
|
+
revision,
|
|
400
|
+
occurredAt,
|
|
401
|
+
}));
|
|
402
|
+
const cost = parseSessionCost(context.content)
|
|
403
|
+
|| { model: '?', mainCost: 0, subCost: 0, tokens: 0, subTokens: 0, ledger: [] };
|
|
404
|
+
const sessionProjection = sessionEvents({
|
|
405
|
+
projectId,
|
|
406
|
+
logicalPath: context.file.logicalPath,
|
|
407
|
+
content: context.content,
|
|
408
|
+
cost,
|
|
409
|
+
revision,
|
|
410
|
+
sessionId: context.sessionId,
|
|
411
|
+
}).events;
|
|
412
|
+
events.push(...sessionProjection.filter((event) => !(
|
|
413
|
+
eventName.includes('subagentstop') && event.kind === 'transcript.upsert'
|
|
414
|
+
)));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const mainAgentId = `${projectId}:${context.sessionId}:main`;
|
|
418
|
+
const provider = text(context.fm.provider);
|
|
419
|
+
const model = text(context.fm.modelo || context.fm.custo_modelo_label, '?');
|
|
420
|
+
let sources = sourceCandidates({
|
|
421
|
+
vaultBase,
|
|
422
|
+
logicalPath: context.file.logicalPath,
|
|
423
|
+
fm: context.fm,
|
|
424
|
+
input,
|
|
425
|
+
});
|
|
426
|
+
if (input.agent_transcript_path || input.agentTranscriptPath || eventName.includes('subagentstop')) {
|
|
427
|
+
sources = sources.filter((source) => source.role === 'subagent').slice(0, 1);
|
|
428
|
+
} else {
|
|
429
|
+
sources = sources.filter((source) => source.role === 'main').slice(0, 1);
|
|
430
|
+
}
|
|
431
|
+
for (const source of sources) {
|
|
432
|
+
const transcriptContent = readFileSync(source.path, 'utf8');
|
|
433
|
+
const fingerprint = hash(transcriptContent);
|
|
434
|
+
const previousTranscript = state.transcripts?.[source.transcriptId];
|
|
435
|
+
if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
|
|
436
|
+
const complete = completeTranscriptEvents({
|
|
437
|
+
projectId,
|
|
438
|
+
sessionId: context.sessionId,
|
|
439
|
+
mainAgentId,
|
|
440
|
+
provider,
|
|
441
|
+
model,
|
|
442
|
+
source,
|
|
443
|
+
now: occurredAt,
|
|
444
|
+
captureLevel: resolvedCaptureLevel,
|
|
445
|
+
});
|
|
446
|
+
nextState.transcripts[source.transcriptId] = {
|
|
447
|
+
content_hash: complete.fingerprint,
|
|
448
|
+
coverage: resolvedCaptureLevel,
|
|
449
|
+
};
|
|
450
|
+
events.push(...complete.events);
|
|
451
|
+
changed += 1;
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
events: dedupeEvents(events),
|
|
455
|
+
nextState,
|
|
456
|
+
scanned: 1,
|
|
457
|
+
changed,
|
|
458
|
+
scope: `session:${context.sessionId}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
324
462
|
function queueOutbox(vaultBase, batch) {
|
|
325
463
|
mkdirSync(outboxDir(vaultBase), { recursive: true });
|
|
326
464
|
const path = outboxPath(vaultBase, batch);
|
|
@@ -328,14 +466,197 @@ function queueOutbox(vaultBase, batch) {
|
|
|
328
466
|
return path;
|
|
329
467
|
}
|
|
330
468
|
|
|
469
|
+
function eventCoalesceKey(event) {
|
|
470
|
+
const payload = event.payload || {};
|
|
471
|
+
const entityFields = {
|
|
472
|
+
'document.upsert': ['logical_path'],
|
|
473
|
+
'document.delete': ['logical_path'],
|
|
474
|
+
'session.upsert': ['session_id'],
|
|
475
|
+
'agent.upsert': ['agent_id'],
|
|
476
|
+
'usage.rollup': ['rollup_key'],
|
|
477
|
+
llm_call: ['call_id'],
|
|
478
|
+
'transcript.upsert': ['transcript_id'],
|
|
479
|
+
}[event.kind] || [];
|
|
480
|
+
const entityId = entityFields.map((field) => payload[field]).find(Boolean) || event.event_id;
|
|
481
|
+
return [
|
|
482
|
+
event.project_id,
|
|
483
|
+
event.kind,
|
|
484
|
+
entityId,
|
|
485
|
+
].join('\u001f');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
|
|
489
|
+
function waitSync(milliseconds) { Atomics.wait(WAIT_ARRAY, 0, 0, milliseconds); }
|
|
490
|
+
|
|
491
|
+
function acquireBatchFileLease(path, waitMs = 0) {
|
|
492
|
+
const lock = `${path}.lock`;
|
|
493
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
494
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
495
|
+
do {
|
|
496
|
+
try {
|
|
497
|
+
mkdirSync(lock);
|
|
498
|
+
atomicJson(join(lock, 'owner.json'), { token, pid: process.pid, acquired_at: new Date().toISOString() });
|
|
499
|
+
return { path: lock, token };
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
502
|
+
try {
|
|
503
|
+
if (Date.now() - statSync(lock).mtimeMs > SQL_LEASE_STALE_MS) {
|
|
504
|
+
const stale = `${lock}.stale-${process.pid}-${Date.now()}`;
|
|
505
|
+
renameSync(lock, stale);
|
|
506
|
+
rmSync(stale, { recursive: true, force: true });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
} catch { /* another process recovered or released it */ }
|
|
510
|
+
if (Date.now() >= deadline) return null;
|
|
511
|
+
waitSync(Math.min(5, Math.max(1, deadline - Date.now())));
|
|
512
|
+
}
|
|
513
|
+
} while (Date.now() <= deadline);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function releaseBatchFileLease(lease) {
|
|
518
|
+
if (!lease) return;
|
|
519
|
+
const owner = readJson(join(lease.path, 'owner.json'), {});
|
|
520
|
+
if (owner.token === lease.token) rmSync(lease.path, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Queue a precise writer batch and replace older pending state for the same logical scope. */
|
|
524
|
+
export function enqueueObserverSqlBatch(vaultBase, batch, { scope = 'incremental', now = new Date() } = {}) {
|
|
525
|
+
if (!batch?.project_id || !Array.isArray(batch.events)) throw new Error('Batch incremental do Observer inválido.');
|
|
526
|
+
if (!batch.events.length) return { queued: false, events: 0, path: '' };
|
|
527
|
+
mkdirSync(outboxDir(vaultBase), { recursive: true });
|
|
528
|
+
const path = join(outboxDir(vaultBase), `sql-live-${hash(`${batch.project_id}:${scope}`).slice(0, 24)}.json`);
|
|
529
|
+
const lease = acquireBatchFileLease(path, 200);
|
|
530
|
+
if (!lease) {
|
|
531
|
+
const fallback = queueOutbox(vaultBase, { ...batch, enqueued_at: isoNow(now), scope });
|
|
532
|
+
return { queued: true, events: batch.events.length, path: fallback, coalesced: false };
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
const existing = readJson(path, { events: [] });
|
|
536
|
+
const merged = new Map();
|
|
537
|
+
for (const event of [...(existing.events || []), ...batch.events]) merged.set(eventCoalesceKey(event), event);
|
|
538
|
+
const timestamp = isoNow(now);
|
|
539
|
+
atomicJson(path, {
|
|
540
|
+
schema_version: SQL_SCHEMA_VERSION,
|
|
541
|
+
project_id: batch.project_id,
|
|
542
|
+
scope,
|
|
543
|
+
enqueued_at: existing.enqueued_at || timestamp,
|
|
544
|
+
updated_at: timestamp,
|
|
545
|
+
events: [...merged.values()],
|
|
546
|
+
});
|
|
547
|
+
return { queued: true, events: merged.size, path, coalesced: true };
|
|
548
|
+
} finally {
|
|
549
|
+
releaseBatchFileLease(lease);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Writer seam for note/archive commands: enqueue exactly one known logical document. */
|
|
554
|
+
export function enqueueObserverDocumentChange({
|
|
555
|
+
vaultBase,
|
|
556
|
+
projectId,
|
|
557
|
+
logicalPath,
|
|
558
|
+
deleted = false,
|
|
559
|
+
now = new Date(),
|
|
560
|
+
} = {}) {
|
|
561
|
+
const normalized = normalizePath(logicalPath);
|
|
562
|
+
if (!vaultBase || !projectId || !normalized || normalized.startsWith('/') || normalized.includes('..')) {
|
|
563
|
+
throw new Error('Documento incremental do Observer inválido.');
|
|
564
|
+
}
|
|
565
|
+
const state = readState(vaultBase);
|
|
566
|
+
const nextState = {
|
|
567
|
+
schema_version: SQL_SCHEMA_VERSION,
|
|
568
|
+
files: { ...(state.files || {}) },
|
|
569
|
+
transcripts: { ...(state.transcripts || {}) },
|
|
570
|
+
};
|
|
571
|
+
const previous = state.files?.[normalized];
|
|
572
|
+
const revision = Math.max(1, Number(previous?.revision || 0) + 1);
|
|
573
|
+
const occurredAt = isoNow(now);
|
|
574
|
+
let event;
|
|
575
|
+
if (deleted) {
|
|
576
|
+
if (!previous) return { queued: false, unchanged: true, events: 0 };
|
|
577
|
+
event = {
|
|
578
|
+
schema_version: 1,
|
|
579
|
+
event_id: eventId('document-delete', projectId, `${normalized}:${revision}`),
|
|
580
|
+
kind: 'document.delete',
|
|
581
|
+
project_id: projectId,
|
|
582
|
+
occurred_at: occurredAt,
|
|
583
|
+
payload: { logical_path: normalized, revision },
|
|
584
|
+
};
|
|
585
|
+
nextState.files[normalized] = { ...previous, revision, deleted: true };
|
|
586
|
+
} else {
|
|
587
|
+
const absolute = join(vaultBase, normalized);
|
|
588
|
+
if (!existsSync(absolute)) return { queued: false, missing: true, events: 0 };
|
|
589
|
+
const content = readFileSync(absolute, 'utf8');
|
|
590
|
+
const metadata = parseFrontmatter(content);
|
|
591
|
+
const contentHash = hash(sanitizeObserverContent(content));
|
|
592
|
+
if (previous?.content_hash === contentHash && previous?.deleted !== true) {
|
|
593
|
+
return { queued: false, unchanged: true, events: 0 };
|
|
594
|
+
}
|
|
595
|
+
event = documentEvent({ projectId, logicalPath: normalized, content, metadata, revision, occurredAt });
|
|
596
|
+
nextState.files[normalized] = { content_hash: contentHash, revision };
|
|
597
|
+
}
|
|
598
|
+
const queued = enqueueObserverSqlBatch(vaultBase, {
|
|
599
|
+
schema_version: SQL_SCHEMA_VERSION,
|
|
600
|
+
project_id: projectId,
|
|
601
|
+
events: [event],
|
|
602
|
+
}, { scope: `document:${normalized}`, now });
|
|
603
|
+
atomicJson(statePath(vaultBase), nextState);
|
|
604
|
+
return { ...queued, event_id: event.event_id, deleted };
|
|
605
|
+
}
|
|
606
|
+
|
|
331
607
|
export function listSqlOutbox(vaultBase) {
|
|
332
608
|
const dir = outboxDir(vaultBase);
|
|
333
609
|
if (!existsSync(dir)) return [];
|
|
334
|
-
return readdirSync(dir).filter((name) => /^sql-[a-f0-9]{24}\.json$/.test(name)).sort().flatMap((name) => {
|
|
610
|
+
return readdirSync(dir).filter((name) => /^sql-(?:live-)?[a-f0-9]{24}\.json$/.test(name)).sort().flatMap((name) => {
|
|
335
611
|
try { return [{ path: join(dir, name), ...JSON.parse(readFileSync(join(dir, name), 'utf8')) }]; } catch { return []; }
|
|
336
612
|
});
|
|
337
613
|
}
|
|
338
614
|
|
|
615
|
+
export function inspectObserverSqlOutbox(vaultBase, currentTime = Date.now()) {
|
|
616
|
+
const batches = listSqlOutbox(vaultBase);
|
|
617
|
+
let bytes = 0;
|
|
618
|
+
let oldestAt = '';
|
|
619
|
+
let events = 0;
|
|
620
|
+
for (const batch of batches) {
|
|
621
|
+
try { bytes += statSync(batch.path).size; } catch { /* raced with a drain */ }
|
|
622
|
+
events += Array.isArray(batch.events) ? batch.events.length : 0;
|
|
623
|
+
const candidate = text(batch.enqueued_at || batch.updated_at);
|
|
624
|
+
if (candidate && (!oldestAt || candidate < oldestAt)) oldestAt = candidate;
|
|
625
|
+
}
|
|
626
|
+
const oldestAgeMs = oldestAt ? Math.max(0, Number(currentTime) - Date.parse(oldestAt)) : 0;
|
|
627
|
+
return { batches: batches.length, events, bytes, oldest_at: oldestAt, oldest_age_ms: oldestAgeMs };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function leasePath(vaultBase) { return join(vaultBase, SQL_PUBLISHER_LEASE_REL); }
|
|
631
|
+
|
|
632
|
+
function acquirePublisherLease(vaultBase, currentTime = Date.now()) {
|
|
633
|
+
const path = leasePath(vaultBase);
|
|
634
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
635
|
+
const token = `${process.pid}-${currentTime}-${Math.random().toString(16).slice(2)}`;
|
|
636
|
+
try {
|
|
637
|
+
mkdirSync(path);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
640
|
+
let age = 0;
|
|
641
|
+
try { age = currentTime - statSync(path).mtimeMs; } catch { return null; }
|
|
642
|
+
if (age <= SQL_LEASE_STALE_MS) return null;
|
|
643
|
+
const stale = `${path}.stale-${token}`;
|
|
644
|
+
try {
|
|
645
|
+
renameSync(path, stale);
|
|
646
|
+
rmSync(stale, { recursive: true, force: true });
|
|
647
|
+
mkdirSync(path);
|
|
648
|
+
} catch { return null; }
|
|
649
|
+
}
|
|
650
|
+
atomicJson(join(path, 'owner.json'), { token, pid: process.pid, acquired_at: new Date(currentTime).toISOString() });
|
|
651
|
+
return { path, token };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function releasePublisherLease(lease) {
|
|
655
|
+
if (!lease) return;
|
|
656
|
+
const owner = readJson(join(lease.path, 'owner.json'), {});
|
|
657
|
+
if (owner.token === lease.token) rmSync(lease.path, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
|
|
339
660
|
async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fetch, token = '' }) {
|
|
340
661
|
const controller = new AbortController();
|
|
341
662
|
const rawBody = Buffer.from(JSON.stringify({ events }), 'utf8');
|
|
@@ -402,38 +723,91 @@ async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fet
|
|
|
402
723
|
export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '' } = {}) {
|
|
403
724
|
const pending = listSqlOutbox(vaultBase);
|
|
404
725
|
if (!url) return { attempted: 0, confirmed: 0, pending: pending.length };
|
|
726
|
+
const lease = acquirePublisherLease(vaultBase);
|
|
727
|
+
if (!lease) return { attempted: 0, confirmed: 0, pending: pending.length, busy: true };
|
|
405
728
|
let attempted = 0;
|
|
406
729
|
let confirmed = 0;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
730
|
+
try {
|
|
731
|
+
for (const batch of pending) {
|
|
732
|
+
if (batch.project_id && batch.project_id !== projectId) continue;
|
|
733
|
+
const batchLease = acquireBatchFileLease(batch.path);
|
|
734
|
+
if (!batchLease) continue;
|
|
735
|
+
attempted += 1;
|
|
736
|
+
try {
|
|
737
|
+
const current = readJson(batch.path, null);
|
|
738
|
+
if (!current?.events?.length) continue;
|
|
739
|
+
await postSqlBatch({ url, projectId, events: current.events, fetchImpl, token });
|
|
740
|
+
if (existsSync(batch.path)) unlinkSync(batch.path);
|
|
741
|
+
confirmed += 1;
|
|
742
|
+
} catch { break; }
|
|
743
|
+
finally { releaseBatchFileLease(batchLease); }
|
|
744
|
+
}
|
|
745
|
+
return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length, busy: false };
|
|
746
|
+
} finally {
|
|
747
|
+
releasePublisherLease(lease);
|
|
414
748
|
}
|
|
415
|
-
return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length };
|
|
416
749
|
}
|
|
417
750
|
|
|
418
|
-
|
|
751
|
+
/** Hook path: enqueue at most one touched session/subagent, then drain under one lease. */
|
|
752
|
+
export async function publishObserverSqlIncremental({
|
|
753
|
+
vaultBase,
|
|
754
|
+
projectId,
|
|
755
|
+
url = process.env.WENDKEEP_OBSERVER_URL || '',
|
|
756
|
+
input = {},
|
|
757
|
+
now = new Date(),
|
|
758
|
+
fetchImpl = globalThis.fetch,
|
|
759
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
760
|
+
captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
|
|
761
|
+
} = {}) {
|
|
762
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
763
|
+
const state = readState(vaultBase);
|
|
764
|
+
const batch = buildObserverSqlIncrementalBatch({
|
|
765
|
+
vaultBase, projectId, input, now, state, captureLevel,
|
|
766
|
+
});
|
|
767
|
+
const queued = enqueueObserverSqlBatch(vaultBase, {
|
|
768
|
+
schema_version: SQL_SCHEMA_VERSION,
|
|
769
|
+
project_id: projectId,
|
|
770
|
+
events: batch.events,
|
|
771
|
+
}, { scope: batch.scope, now });
|
|
772
|
+
if (batch.events.length) atomicJson(statePath(vaultBase), batch.nextState);
|
|
773
|
+
const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
|
|
774
|
+
const pending = listSqlOutbox(vaultBase).length;
|
|
775
|
+
return {
|
|
776
|
+
ok: pending === 0,
|
|
777
|
+
queued: pending > 0,
|
|
778
|
+
scanned: batch.scanned,
|
|
779
|
+
changed: batch.changed,
|
|
780
|
+
enqueued_events: queued.events || 0,
|
|
781
|
+
pending,
|
|
782
|
+
replay,
|
|
783
|
+
hookExitCode: 0,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
|
|
419
788
|
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
420
789
|
const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
|
|
421
790
|
const state = readState(vaultBase);
|
|
422
|
-
const remoteDocuments = Object.keys(state.files || {}).length === 0
|
|
791
|
+
const remoteDocuments = forceFull || Object.keys(state.files || {}).length === 0
|
|
423
792
|
? await readRemoteDocuments({ url, projectId, fetchImpl, token })
|
|
424
793
|
: {};
|
|
425
|
-
const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel });
|
|
426
|
-
|
|
427
|
-
|
|
794
|
+
const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel, forceFull });
|
|
795
|
+
if (!batch.events.length) {
|
|
796
|
+
atomicJson(statePath(vaultBase), batch.nextState);
|
|
797
|
+
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
|
|
798
|
+
}
|
|
428
799
|
if (!url) {
|
|
429
800
|
queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
|
|
801
|
+
atomicJson(statePath(vaultBase), batch.nextState);
|
|
430
802
|
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0 };
|
|
431
803
|
}
|
|
432
804
|
try {
|
|
433
805
|
const response = await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
|
|
806
|
+
atomicJson(statePath(vaultBase), batch.nextState);
|
|
434
807
|
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, response };
|
|
435
808
|
} catch (error) {
|
|
436
809
|
queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
|
|
810
|
+
atomicJson(statePath(vaultBase), batch.nextState);
|
|
437
811
|
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0, error: error.message };
|
|
438
812
|
}
|
|
439
813
|
}
|