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.
@@ -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 callId = eventId('call', projectId, `${transcriptId}:${agentId}:${turn.turnId || index + 1}:${hash(content).slice(0, 16)}`);
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
- for (const batch of pending) {
408
- attempted += 1;
409
- try {
410
- await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
411
- unlinkSync(batch.path);
412
- confirmed += 1;
413
- } catch { break; }
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
- 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' } = {}) {
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
- atomicJson(statePath(vaultBase), batch.nextState);
427
- if (!batch.events.length) return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
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
  }