@interactive-inc/claude-funnel 0.57.0 → 0.58.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.
Files changed (49) hide show
  1. package/dist/bin.js +258 -219
  2. package/dist/claude.d.ts +5 -5
  3. package/dist/claude.js +2 -2
  4. package/dist/{connector-adapter-1PxjN-Uk.d.ts → connector-adapter-DGacCppE.d.ts} +1 -1
  5. package/dist/connectors/discord.d.ts +3 -20
  6. package/dist/connectors/discord.js +1 -1
  7. package/dist/connectors/gh.d.ts +4 -4
  8. package/dist/connectors/schedule.d.ts +1 -1
  9. package/dist/connectors/slack.d.ts +2 -2
  10. package/dist/connectors/slack.js +1 -1
  11. package/dist/diagnostics.d.ts +1 -1
  12. package/dist/{discord-connector-schema-CPgcZkXh.d.ts → discord-connector-schema-CQyfDkLD.d.ts} +18 -1
  13. package/dist/{discord-listener-C0MoKdQO.js → discord-listener-CKsZGTnH.js} +1 -1
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/doctor.d.ts +1 -1
  16. package/dist/{file-process-guard-DI1742H5.d.ts → file-process-guard-B3IFCj_G.d.ts} +5 -5
  17. package/dist/{funnel-diagnostics-qWy5tPSq.d.ts → funnel-diagnostics-K-wON25Y.d.ts} +1 -1
  18. package/dist/{funnel-doctor-BF3Rdgk0.d.ts → funnel-doctor-vxO96TCA.d.ts} +2 -2
  19. package/dist/funnel-log-sqlite-sink-B_5_4ybn.js +301 -0
  20. package/dist/{funnel-recovery-BUBsu7WX.d.ts → funnel-recovery-COExL9MD.d.ts} +1 -1
  21. package/dist/gateway/daemon.js +196 -196
  22. package/dist/gateway.d.ts +2 -2
  23. package/dist/gateway.js +1 -1
  24. package/dist/{index-DEeCwhk2.d.ts → index-B9iyugar.d.ts} +49 -14
  25. package/dist/index.d.ts +17 -16
  26. package/dist/index.js +76 -11
  27. package/dist/{local-config-sync-E_t5_fjw.d.ts → local-config-sync--f739oCJ.d.ts} +8 -8
  28. package/dist/local-config.d.ts +2 -2
  29. package/dist/local-config.js +1 -1
  30. package/dist/logger.d.ts +384 -0
  31. package/dist/logger.js +281 -0
  32. package/dist/{memory-diagnostic-log-BbFVqDzz.js → memory-diagnostic-log-5LzwJ_F7.js} +110 -323
  33. package/dist/{memory-token-prompter-DpCC1_Dn.d.ts → memory-token-prompter-BlFwK9k7.d.ts} +2 -2
  34. package/dist/{profiles-EHTeCOqB.d.ts → profiles-g2qGVOWv.d.ts} +3 -3
  35. package/dist/profiles.d.ts +1 -1
  36. package/dist/recovery.d.ts +1 -1
  37. package/dist/{schedule-listener-DKh0hnkK.d.ts → schedule-listener-DoMPjHZj.d.ts} +2 -2
  38. package/dist/{settings-reader-CBrgz01o.d.ts → settings-reader-DPwqOVUm.d.ts} +1 -1
  39. package/dist/{slack-listener-BDyBqatt.js → slack-listener-C4wlZaOq.js} +18 -5
  40. package/dist/{slack-listener-DFlAzMc7.d.ts → slack-listener-Dj9NFbAJ.d.ts} +2 -1
  41. package/dist/{yaml-render-OhUN-qkS.js → yaml-render-C9Hhjk-0.js} +1 -1
  42. package/package.json +6 -1
  43. /package/dist/{diagnostic-log-Bxe7Bbvw.d.ts → diagnostic-log-Cb3v8P7p.d.ts} +0 -0
  44. /package/dist/{file-system-Wub9Nto4.d.ts → file-system-DxpnnUVb.d.ts} +0 -0
  45. /package/dist/{funnel-docs-dXPokzr5.d.ts → funnel-docs-DYBs1-H_.d.ts} +0 -0
  46. /package/dist/{gh-connector-schema-CU1ojfIF.d.ts → gh-connector-schema-CZzwzvqY.d.ts} +0 -0
  47. /package/dist/{memory-token-prompter-vBXxY20-.js → memory-token-prompter-C7vREzCL.js} +0 -0
  48. /package/dist/{process-runner-D5I_jhYQ.d.ts → process-runner-Cx5O_fTf.d.ts} +0 -0
  49. /package/dist/{settings-schema-zhnMIa8I.d.ts → settings-schema-1hh11jnN.d.ts} +0 -0
@@ -2,11 +2,11 @@ import { n as NodeFunnelProcessRunner } from "./gh-connector-schema-DUcZgN2Q.js"
2
2
  import { t as NodeFunnelFileSystem } from "./node-file-system-BcrmWN9I.js";
3
3
  import { r as FUNNEL_DIR, s as resolveFunnelPort, t as gatewayLoopbackUrl } from "./gateway-base-url-6foMXfFf.js";
4
4
  import { t as ConnectorDiagnosticSqlReader } from "./diagnostic-sql-reader-CzYgZpq2.js";
5
+ import { t as FunnelLogSqliteSink } from "./funnel-log-sqlite-sink-B_5_4ybn.js";
5
6
  import { dirname, join } from "node:path";
6
7
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
7
8
  import { z } from "zod";
8
9
  import { homedir, tmpdir } from "node:os";
9
- import { Database } from "bun:sqlite";
10
10
  import { timingSafeEqual } from "node:crypto";
11
11
  import { createFactory } from "hono/factory";
12
12
  import { HTTPException } from "hono/http-exception";
@@ -360,298 +360,6 @@ const funnelEventSchema = z.object({
360
360
  */
361
361
  var FunnelEventLog = class {};
362
362
  //#endregion
363
- //#region lib/logger/leuco-logger-sqlite-sink.ts
364
- /** Conservative whitelist for column names interpolated into SQL. */
365
- const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
366
- /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
367
- const BYTE_CHECK_INTERVAL = 500;
368
- const RESERVED_COLUMNS = new Set([
369
- "seq",
370
- "ts",
371
- "type",
372
- "event"
373
- ]);
374
- /**
375
- * Schema versions. Each entry is the list of DDL statements that take the
376
- * database from version i to version i + 1. Migrations run in a transaction
377
- * so a partial failure rolls back. Adding a new version is append-only —
378
- * never edit a published one. Caller-defined index columns are added
379
- * dynamically on construct (independent of versioned migrations) because
380
- * they are configuration, not schema evolution.
381
- */
382
- const MIGRATIONS = [[
383
- "CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
384
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
385
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
386
- ]];
387
- /**
388
- * SQLite-backed sink built on `bun:sqlite`. Implements both primary and
389
- * relay roles so the same instance can own seq generation for one bus and
390
- * mirror records from another (e.g. cross-process replication, restore
391
- * from a backup stream).
392
- *
393
- * Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
394
- * atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
395
- * at the same database file therefore see one monotonically increasing
396
- * seq stream without any bus-level coordination — the database itself is
397
- * the synchronization point.
398
- *
399
- * Schema is version-managed via `PRAGMA user_version`. Migrations are
400
- * append-only and run in a transaction on every construct so a partial
401
- * upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
402
- * via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
403
- * a new index to an existing database is a no-downtime operation.
404
- *
405
- * Type safety: the second generic parameter `I` is the literal tuple of
406
- * index column names. `extractIndexes` and `getRecords({ where })` are
407
- * both type-checked against this tuple, so a typo at the call site is a
408
- * compile-time error rather than a silent miss at runtime.
409
- *
410
- * Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
411
- * insert as a single indexed DELETE that no-ops below the cap.
412
- *
413
- * Bulk inserts use `insertMany`, which wraps the batch in one transaction
414
- * for ~10–100x throughput at the cost of one fsync per batch instead of
415
- * one per row.
416
- */
417
- var LeucoLoggerSqliteSink = class {
418
- db;
419
- maxRows;
420
- maxAgeMs;
421
- maxBytes;
422
- targetBytes;
423
- now;
424
- indexes;
425
- extractIndexes;
426
- insertStmt;
427
- insertWithSeqStmt;
428
- maxSeqStmt;
429
- countStmt;
430
- trimRowsStmt;
431
- trimAgeStmt;
432
- trimOldestStmt;
433
- insertsSinceByteCheck = 0;
434
- constructor(props) {
435
- if (props.path !== ":memory:") {
436
- const dir = dirname(props.path);
437
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
438
- }
439
- this.db = new Database(props.path);
440
- this.db.run("PRAGMA journal_mode = WAL");
441
- this.migrate();
442
- this.maxRows = props.maxRows ?? null;
443
- this.maxAgeMs = props.maxAgeMs ?? null;
444
- this.maxBytes = props.maxBytes ?? null;
445
- this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
446
- this.now = props.now ?? (() => Date.now());
447
- this.indexes = props.indexes ?? [];
448
- if (this.indexes.length > 0) {
449
- validateIndexNames(this.indexes);
450
- this.extractIndexes = props.extractIndexes ?? null;
451
- this.syncIndexColumns();
452
- } else this.extractIndexes = null;
453
- const cols = [
454
- "ts",
455
- "type",
456
- "event",
457
- ...this.indexes
458
- ];
459
- const placeholders = cols.map(() => "?").join(", ");
460
- this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
461
- const colsWithSeq = ["seq", ...cols];
462
- const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
463
- this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
464
- this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
465
- this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
466
- this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
467
- this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
468
- this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
469
- }
470
- insert(input) {
471
- try {
472
- const params = this.buildInsertParams(input.ts, input.event);
473
- const result = this.insertStmt.run(...params);
474
- const seq = Number(result.lastInsertRowid);
475
- this.trim();
476
- return {
477
- seq,
478
- ts: input.ts,
479
- event: input.event
480
- };
481
- } catch (e) {
482
- return e instanceof Error ? e : new Error(String(e));
483
- }
484
- }
485
- insertMany(inputs) {
486
- if (inputs.length === 0) return [];
487
- try {
488
- const records = [];
489
- this.db.transaction((batch) => {
490
- for (const input of batch) {
491
- const params = this.buildInsertParams(input.ts, input.event);
492
- const result = this.insertStmt.run(...params);
493
- records.push({
494
- seq: Number(result.lastInsertRowid),
495
- ts: input.ts,
496
- event: input.event
497
- });
498
- }
499
- })(inputs);
500
- this.trim();
501
- return records;
502
- } catch (e) {
503
- return e instanceof Error ? e : new Error(String(e));
504
- }
505
- }
506
- write(record) {
507
- try {
508
- const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
509
- this.insertWithSeqStmt.run(...params);
510
- this.trim();
511
- } catch (e) {
512
- return e instanceof Error ? e : new Error(String(e));
513
- }
514
- }
515
- getMaxSeq() {
516
- const row = this.maxSeqStmt.get();
517
- return row ? row.max : 0;
518
- }
519
- getRecords(props = {}) {
520
- const conditions = ["seq > ?"];
521
- const params = [props.sinceSeq ?? 0];
522
- if (typeof props.type === "string") {
523
- conditions.push("type = ?");
524
- params.push(props.type);
525
- }
526
- if (props.where) this.appendWhereConditions(props.where, conditions, params);
527
- const limit = props.limit ?? 1e3;
528
- params.push(limit);
529
- const dir = props.order === "desc" ? "DESC" : "ASC";
530
- const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
531
- const rows = this.db.prepare(sql).all(...params);
532
- if (dir === "DESC") rows.reverse();
533
- return rows.map(toRecord);
534
- }
535
- /**
536
- * Current schema version. Useful for diagnostics and for tests that want
537
- * to verify migrations ran. Reads `PRAGMA user_version` once per call.
538
- */
539
- getSchemaVersion() {
540
- return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
541
- }
542
- close() {
543
- this.db.close();
544
- }
545
- buildInsertParams(ts, event) {
546
- const type = extractType(event);
547
- const json = JSON.stringify(event);
548
- if (this.indexes.length === 0) return [
549
- ts,
550
- type,
551
- json
552
- ];
553
- const values = this.extractIndexes ? this.extractIndexes(event) : null;
554
- return [
555
- ts,
556
- type,
557
- json,
558
- ...this.indexes.map((col) => values?.[col] ?? null)
559
- ];
560
- }
561
- appendWhereConditions(where, conditions, params) {
562
- const widened = where;
563
- for (const col of this.indexes) {
564
- const value = widened[col];
565
- if (value === void 0) continue;
566
- if (value === null) conditions.push(`${col} IS NULL`);
567
- else {
568
- conditions.push(`${col} = ?`);
569
- params.push(value);
570
- }
571
- }
572
- }
573
- trim() {
574
- if (this.maxRows !== null) {
575
- const row = this.countStmt.get();
576
- if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
577
- }
578
- if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
579
- this.maybeTrimBytes();
580
- }
581
- /**
582
- * Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
583
- * we measure the file; on overflow we estimate how many of the oldest rows to
584
- * drop to land near targetBytes (by the byte/row ratio), delete them in one
585
- * statement, then VACUUM once to return the freed pages to the filesystem (a
586
- * plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
587
- * overflow keeps the expensive rewrite rare — the file must refill the whole
588
- * maxBytes→targetBytes delta before the next overflow can trigger.
589
- */
590
- maybeTrimBytes() {
591
- if (this.maxBytes === null || this.targetBytes === null) return;
592
- this.insertsSinceByteCheck += 1;
593
- if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
594
- this.insertsSinceByteCheck = 0;
595
- const bytes = this.byteSize();
596
- if (bytes <= this.maxBytes) return;
597
- const rows = this.countStmt.get()?.n ?? 0;
598
- if (rows === 0) return;
599
- const bytesToFree = bytes - this.targetBytes;
600
- const bytesPerRow = bytes / rows;
601
- const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
602
- this.trimOldestStmt.run(rowsToDrop);
603
- this.db.run("VACUUM");
604
- }
605
- byteSize() {
606
- return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
607
- }
608
- /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
609
- clear() {
610
- this.db.run("DELETE FROM leuco_log");
611
- this.db.run("VACUUM");
612
- this.insertsSinceByteCheck = 0;
613
- }
614
- syncIndexColumns() {
615
- const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
616
- for (const col of this.indexes) {
617
- if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
618
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
619
- }
620
- }
621
- migrate() {
622
- const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
623
- if (current >= MIGRATIONS.length) return;
624
- const pending = MIGRATIONS.slice(current);
625
- let version = current;
626
- for (const stmts of pending) {
627
- version += 1;
628
- this.db.transaction(() => {
629
- for (const stmt of stmts) this.db.run(stmt);
630
- this.db.run(`PRAGMA user_version = ${version}`);
631
- })();
632
- }
633
- }
634
- };
635
- function validateIndexNames(names) {
636
- for (const name of names) {
637
- if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
638
- if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
639
- }
640
- }
641
- function extractType(event) {
642
- if (typeof event !== "object" || event === null) return null;
643
- if (!("type" in event)) return null;
644
- const t = event.type;
645
- return typeof t === "string" ? t : null;
646
- }
647
- function toRecord(row) {
648
- return {
649
- seq: row.seq,
650
- ts: row.ts,
651
- event: JSON.parse(row.event)
652
- };
653
- }
654
- //#endregion
655
363
  //#region lib/gateway/event-log/sqlite-event-log.ts
656
364
  const MAX_CONTENT_CHARS = 2e3;
657
365
  /**
@@ -679,7 +387,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
679
387
  super();
680
388
  this.now = props.now ?? (() => Date.now());
681
389
  this.logger = props.logger;
682
- this.sink = new LeucoLoggerSqliteSink({
390
+ this.sink = new FunnelLogSqliteSink({
683
391
  path: props.path,
684
392
  indexes: ["channel_id", "connector_id"],
685
393
  extractIndexes: (event) => ({
@@ -722,7 +430,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
722
430
  * so this returns the full slice and lets the caller filter.
723
431
  */
724
432
  loadSince(since) {
725
- const records = this.sink.getRecords({ sinceSeq: since });
433
+ const records = this.sink.query({ sinceSeq: since });
726
434
  const out = [];
727
435
  for (const record of records) out.push({
728
436
  content: record.event.content,
@@ -739,7 +447,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
739
447
  loadForChannel(props) {
740
448
  const where = { channel_id: props.channelId };
741
449
  if (props.connectorId !== void 0) where.connector_id = props.connectorId;
742
- const records = this.sink.getRecords({
450
+ const records = this.sink.query({
743
451
  where,
744
452
  ...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
745
453
  ...props.limit !== void 0 ? { limit: props.limit } : {}
@@ -771,7 +479,8 @@ function truncate(content) {
771
479
  const defaultOnError$1 = () => {};
772
480
  const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
773
481
  const DEFAULT_MAX_BACKOFF_MS = 6e4;
774
- const defaultSleep = (ms) => new Promise((r) => {
482
+ const DEFAULT_START_TIMEOUT_MS = 3e4;
483
+ const defaultSleep$1 = (ms) => new Promise((r) => {
775
484
  setTimeout(r, ms);
776
485
  });
777
486
  /**
@@ -796,10 +505,13 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
796
505
  stats = /* @__PURE__ */ new Map();
797
506
  healthCheckIntervalMs;
798
507
  maxBackoffMs;
508
+ startTimeoutMs;
799
509
  sleep;
800
510
  now;
801
511
  healthCheckTimer = null;
802
512
  healthCheckInFlight = false;
513
+ /** Connectors that failed initial start — retried by the health check. */
514
+ pendingRetry = /* @__PURE__ */ new Map();
803
515
  constructor(deps) {
804
516
  this.channels = deps.channels;
805
517
  this.notify = deps.notify;
@@ -807,7 +519,8 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
807
519
  this.onError = deps.onError ?? defaultOnError$1;
808
520
  this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
809
521
  this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
810
- this.sleep = deps.sleep ?? defaultSleep;
522
+ this.startTimeoutMs = deps.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
523
+ this.sleep = deps.sleep ?? defaultSleep$1;
811
524
  this.now = deps.now ?? (() => Date.now());
812
525
  }
813
526
  static keyOf(channelName, connectorName) {
@@ -853,13 +566,16 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
853
566
  }
854
567
  };
855
568
  try {
856
- await created.listener.start(bind);
569
+ await Promise.race([created.listener.start(bind), this.sleep(this.startTimeoutMs).then(() => {
570
+ throw new Error(`listener start timed out after ${this.startTimeoutMs}ms`);
571
+ })]);
857
572
  this.running.set(key, {
858
573
  config: created.config,
859
574
  channelName,
860
575
  channelId: created.channelId,
861
576
  listener: created.listener
862
577
  });
578
+ this.pendingRetry.delete(key);
863
579
  this.ensureStats(key);
864
580
  this.logger?.info(`${created.config.type} listener started`, {
865
581
  channel: channelName,
@@ -894,8 +610,6 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
894
610
  };
895
611
  try {
896
612
  await entry.listener.stop();
897
- this.running.delete(key);
898
- this.failureCounts.delete(key);
899
613
  this.logger?.info(`${entry.config.type} listener stopped`, {
900
614
  channel: channelName,
901
615
  connector: connectorName
@@ -918,6 +632,9 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
918
632
  ok: false,
919
633
  reason: err.message
920
634
  };
635
+ } finally {
636
+ this.running.delete(key);
637
+ this.failureCounts.delete(key);
921
638
  }
922
639
  }
923
640
  async restart(channelName, connectorName) {
@@ -927,11 +644,23 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
927
644
  }
928
645
  async startAll() {
929
646
  const all = this.channels.listAllConnectors();
930
- for (const view of all) await this.start(view.channelName, view.name);
647
+ const results = await Promise.allSettled(all.map((view) => this.start(view.channelName, view.name)));
648
+ for (let i = 0; i < results.length; i++) {
649
+ const result = results[i];
650
+ const view = all[i];
651
+ if (result.status === "rejected" || result.status === "fulfilled" && !result.value.ok) {
652
+ const key = FunnelListenerSupervisor.keyOf(view.channelName, view.name);
653
+ this.pendingRetry.set(key, {
654
+ channelName: view.channelName,
655
+ connectorName: view.name
656
+ });
657
+ }
658
+ }
931
659
  this.startHealthCheck();
932
660
  }
933
661
  async stopAll() {
934
662
  this.stopHealthCheck();
663
+ this.pendingRetry.clear();
935
664
  for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
936
665
  }
937
666
  ensureStats(key) {
@@ -966,6 +695,10 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
966
695
  clearInterval(this.healthCheckTimer);
967
696
  this.healthCheckTimer = null;
968
697
  }
698
+ /** Run one health-check pass synchronously. Test-only seam. */
699
+ async runHealthCheckForTest() {
700
+ await this.runHealthCheck();
701
+ }
969
702
  async runHealthCheck() {
970
703
  if (this.healthCheckInFlight) return;
971
704
  this.healthCheckInFlight = true;
@@ -977,6 +710,23 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
977
710
  }
978
711
  await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
979
712
  }
713
+ for (const [key, pending] of [...this.pendingRetry.entries()]) {
714
+ if (this.running.has(key)) {
715
+ this.pendingRetry.delete(key);
716
+ continue;
717
+ }
718
+ this.logger?.info("retrying failed listener", {
719
+ channel: pending.channelName,
720
+ connector: pending.connectorName
721
+ });
722
+ const failureCount = this.failureCounts.get(key) ?? 0;
723
+ const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
724
+ await this.sleep(backoffMs);
725
+ if ((await this.start(pending.channelName, pending.connectorName)).ok) {
726
+ this.pendingRetry.delete(key);
727
+ this.failureCounts.delete(key);
728
+ } else this.failureCounts.set(key, failureCount + 1);
729
+ }
980
730
  } finally {
981
731
  this.healthCheckInFlight = false;
982
732
  }
@@ -1005,7 +755,27 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
1005
755
  //#endregion
1006
756
  //#region lib/gateway/kill-competing-slack-gateways.ts
1007
757
  const defaultProcess = new NodeFunnelProcessRunner();
758
+ const SIGTERM_GRACE_MS = 3e3;
759
+ const POLL_INTERVAL_MS = 100;
760
+ const SIGKILL_GRACE_MS = 200;
761
+ const defaultSleep = (ms) => new Promise((resolve) => {
762
+ setTimeout(resolve, ms);
763
+ });
1008
764
  const titleFor = (dir) => `funnel-gateway[${dir}]`;
765
+ const waitForExit = async (props) => {
766
+ const deadline = props.now() + SIGTERM_GRACE_MS;
767
+ while (props.now() < deadline) {
768
+ if (props.pids.every((pid) => !props.runner.isAlive(pid))) return;
769
+ await props.sleep(POLL_INTERVAL_MS);
770
+ }
771
+ for (const pid of props.pids) {
772
+ if (!props.runner.isAlive(pid)) continue;
773
+ try {
774
+ props.runner.kill(pid, "SIGKILL");
775
+ } catch {}
776
+ }
777
+ await props.sleep(SIGKILL_GRACE_MS);
778
+ };
1009
779
  /**
1010
780
  * Kills other funnel daemon processes that share the SAME funnel home dir,
1011
781
  * which is the only situation that causes a real conflict (duplicate Slack
@@ -1015,6 +785,10 @@ const titleFor = (dir) => `funnel-gateway[${dir}]`;
1015
785
  * `funnel-gateway[<dir>]` marker appended to argv (also assigned to
1016
786
  * `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
1017
787
  * absorbs the POSIX/Windows enumeration difference behind the marker match.
788
+ *
789
+ * Waits for the killed daemons to actually exit before returning, so the caller
790
+ * can bind the port and open a fresh Socket Mode connection without overlapping
791
+ * the old one (the overlap is what makes Slack split inbound events).
1018
792
  */
1019
793
  const killCompetingSlackGateways = async (props) => {
1020
794
  const runner = props.process ?? defaultProcess;
@@ -1031,6 +805,13 @@ const killCompetingSlackGateways = async (props) => {
1031
805
  args: snapshot.command.slice(0, 160)
1032
806
  });
1033
807
  }
808
+ if (killed.length === 0) return killed;
809
+ await waitForExit({
810
+ runner,
811
+ pids: killed,
812
+ sleep: props.sleep ?? defaultSleep,
813
+ now: props.now ?? (() => Date.now())
814
+ });
1034
815
  return killed;
1035
816
  };
1036
817
  //#endregion
@@ -1290,6 +1071,7 @@ const healthHandler = factory.createHandlers((c) => {
1290
1071
  return c.json({
1291
1072
  ok: true,
1292
1073
  pid: deps.selfPid,
1074
+ funnelDir: deps.dir,
1293
1075
  clients: deps.broadcaster.getClientCount(),
1294
1076
  listeners: deps.supervisor.list()
1295
1077
  });
@@ -1341,6 +1123,7 @@ const statusHandler = factory.createHandlers((c) => {
1341
1123
  return c.json({
1342
1124
  ok: true,
1343
1125
  pid: deps.selfPid,
1126
+ funnelDir: deps.dir,
1344
1127
  uptimeMs: deps.uptimeMs(),
1345
1128
  clients: deps.broadcaster.listChannels(),
1346
1129
  listeners: deps.supervisor.list(),
@@ -1450,6 +1233,7 @@ var FunnelGatewayServer = class {
1450
1233
  if (this.server) return this.server;
1451
1234
  if (!this.token && !LOOPBACK_HOSTS.has(this.hostname) && !this.allowInsecureHost) throw new Error(`refusing to start gateway: hostname "${this.hostname}" is reachable off-box but no token is set. Set a token, bind to loopback (127.0.0.1), or pass allowInsecureHost: true.`);
1452
1235
  const app = this.buildApp();
1236
+ await this.killCompetingSlackIfNeeded();
1453
1237
  this.startedAt = this.nowMs();
1454
1238
  this.server = Bun.serve({
1455
1239
  port: this.port,
@@ -1504,6 +1288,7 @@ var FunnelGatewayServer = class {
1504
1288
  if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
1505
1289
  const requestedChannel = url.searchParams.get("channel") ?? "";
1506
1290
  const channel = requestedChannel ? this.resolveChannel(requestedChannel) : null;
1291
+ if (requestedChannel && !channel) return new Response(`unknown channel "${requestedChannel}"`, { status: 404 });
1507
1292
  const channelId = channel?.id ?? requestedChannel;
1508
1293
  const channelName = channel?.name ?? null;
1509
1294
  const connectors = channel?.connectors ?? [];
@@ -1567,6 +1352,7 @@ var FunnelGatewayServer = class {
1567
1352
  base.use((c, next) => {
1568
1353
  c.set("deps", {
1569
1354
  selfPid: this.selfPid,
1355
+ dir: this.dir,
1570
1356
  broadcaster: this.broadcaster,
1571
1357
  supervisor: this.supervisor,
1572
1358
  channels: this.channels,
@@ -1606,21 +1392,22 @@ var FunnelGatewayServer = class {
1606
1392
  delivery: channel.delivery
1607
1393
  };
1608
1394
  }
1395
+ async killCompetingSlackIfNeeded() {
1396
+ if (!this.killCompetingSlack) return;
1397
+ if (!this.channels.listAllConnectors().some((c) => c.type === "slack")) return;
1398
+ const killed = await killCompetingSlackGateways({
1399
+ selfPid: this.selfPid,
1400
+ dir: this.dir,
1401
+ process: this.process,
1402
+ logger: this.logger
1403
+ });
1404
+ if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
1405
+ event_type: "system",
1406
+ action: "kill_competing",
1407
+ pids: killed.join(",")
1408
+ });
1409
+ }
1609
1410
  async bootListeners() {
1610
- const allConnectors = this.channels.listAllConnectors();
1611
- if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
1612
- const killed = await killCompetingSlackGateways({
1613
- selfPid: this.selfPid,
1614
- dir: this.dir,
1615
- process: this.process,
1616
- logger: this.logger
1617
- });
1618
- if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
1619
- event_type: "system",
1620
- action: "kill_competing",
1621
- pids: killed.join(",")
1622
- });
1623
- }
1624
1411
  await this.supervisor.startAll();
1625
1412
  for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
1626
1413
  event_type: "system",
@@ -1885,7 +1672,7 @@ var ConnectorDiagnosticLog = class {};
1885
1672
  */
1886
1673
  const RAW_PAYLOAD_CAP = 256 * 1024;
1887
1674
  /**
1888
- * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
1675
+ * Default `ConnectorDiagnosticLog`: three independent `FunnelLogSqliteSink`s, one
1889
1676
  * per table (raw / processed / connection), in separate files. Each sink
1890
1677
  * indexes the columns its queries filter on — `event_id` / `connector_id` /
1891
1678
  * `channel_id` for raw, plus `outcome` for processed and `status` for
@@ -1920,7 +1707,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1920
1707
  ...ageCap,
1921
1708
  ...rawMax !== void 0 ? { maxRows: rawMax } : {}
1922
1709
  };
1923
- this.raw = new LeucoLoggerSqliteSink({
1710
+ this.raw = new FunnelLogSqliteSink({
1924
1711
  path: props.rawPath,
1925
1712
  indexes: [
1926
1713
  "event_id",
@@ -1934,7 +1721,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1934
1721
  }),
1935
1722
  ...rawCap
1936
1723
  });
1937
- this.processed = new LeucoLoggerSqliteSink({
1724
+ this.processed = new FunnelLogSqliteSink({
1938
1725
  path: props.processedPath,
1939
1726
  indexes: [
1940
1727
  "event_id",
@@ -1950,7 +1737,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1950
1737
  }),
1951
1738
  ...verdictCap
1952
1739
  });
1953
- this.connection = new LeucoLoggerSqliteSink({
1740
+ this.connection = new FunnelLogSqliteSink({
1954
1741
  path: props.connectionPath,
1955
1742
  indexes: [
1956
1743
  "connector_id",
@@ -2016,7 +1803,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2016
1803
  });
2017
1804
  }
2018
1805
  queryRaw(query) {
2019
- return this.raw.getRecords({
1806
+ return this.raw.query({
2020
1807
  ...query.type !== void 0 ? { type: query.type } : {},
2021
1808
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2022
1809
  where: buildWhere(query),
@@ -2034,7 +1821,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2034
1821
  queryProcessed(query) {
2035
1822
  const where = buildWhere(query);
2036
1823
  if (query.outcome !== void 0) where.outcome = query.outcome;
2037
- return this.processed.getRecords({
1824
+ return this.processed.query({
2038
1825
  ...query.type !== void 0 ? { type: query.type } : {},
2039
1826
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2040
1827
  where,
@@ -2053,7 +1840,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2053
1840
  queryConnection(query) {
2054
1841
  const where = buildWhere(query);
2055
1842
  if (query.status !== void 0) where.status = query.status;
2056
- return this.connection.getRecords({
1843
+ return this.connection.query({
2057
1844
  ...query.type !== void 0 ? { type: query.type } : {},
2058
1845
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2059
1846
  where,
@@ -1,5 +1,5 @@
1
- import { n as FunnelFileSystem } from "./file-system-Wub9Nto4.js";
2
- import { i as FunnelTokenPrompter } from "./local-config-sync-E_t5_fjw.js";
1
+ import { n as FunnelFileSystem } from "./file-system-DxpnnUVb.js";
2
+ import { i as FunnelTokenPrompter } from "./local-config-sync--f739oCJ.js";
3
3
 
4
4
  //#region lib/services/local-config/local-config-json-schema.d.ts
5
5
  /**
@@ -1,6 +1,6 @@
1
- import { r as ProfileConfig } from "./settings-schema-zhnMIa8I.js";
2
- import { n as FunnelIdGenerator, t as FunnelSettingsReader } from "./settings-reader-CBrgz01o.js";
3
- import { n as FunnelFileSystem } from "./file-system-Wub9Nto4.js";
1
+ import { r as ProfileConfig } from "./settings-schema-1hh11jnN.js";
2
+ import { n as FunnelIdGenerator, t as FunnelSettingsReader } from "./settings-reader-DPwqOVUm.js";
3
+ import { n as FunnelFileSystem } from "./file-system-DxpnnUVb.js";
4
4
 
5
5
  //#region lib/engine/profiles/profiles.d.ts
6
6
  type Deps = {
@@ -1,2 +1,2 @@
1
- import { t as FunnelProfiles } from "./profiles-EHTeCOqB.js";
1
+ import { t as FunnelProfiles } from "./profiles-g2qGVOWv.js";
2
2
  export { FunnelProfiles };
@@ -1,2 +1,2 @@
1
- import { a as RecoveryListenerControl, i as RecoveryGatewayControl, n as RecoveryAction, o as RecoveryResult, r as RecoveryChannelSource, t as FunnelRecovery } from "./funnel-recovery-BUBsu7WX.js";
1
+ import { a as RecoveryListenerControl, i as RecoveryGatewayControl, n as RecoveryAction, o as RecoveryResult, r as RecoveryChannelSource, t as FunnelRecovery } from "./funnel-recovery-COExL9MD.js";
2
2
  export { FunnelRecovery, RecoveryAction, RecoveryChannelSource, RecoveryGatewayControl, RecoveryListenerControl, RecoveryResult };
@@ -1,5 +1,5 @@
1
- import { S as FunnelLogger, b as FunnelConnectorListener, o as ConnectorDiagnosticLog, x as NotifyFn } from "./diagnostic-log-Bxe7Bbvw.js";
2
- import { n as FunnelFileSystem } from "./file-system-Wub9Nto4.js";
1
+ import { S as FunnelLogger, b as FunnelConnectorListener, o as ConnectorDiagnosticLog, x as NotifyFn } from "./diagnostic-log-Cb3v8P7p.js";
2
+ import { n as FunnelFileSystem } from "./file-system-DxpnnUVb.js";
3
3
  import { z } from "zod";
4
4
 
5
5
  //#region lib/engine/connectors/schedule-state-store.d.ts