@lightupai/polaris 0.0.77 → 0.0.79

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.
@@ -1,22 +1,24 @@
1
1
  // --- Slack Bridge ---
2
2
  // Connects a project's event stream to a Slack channel.
3
- // Runs server-side, one bridge per org.
3
+ // Runs server-side, one bridge per org (startAllBridges runs one per Slack-connected org).
4
4
  //
5
- // Project → Slack: session events → formatted Slack posts
5
+ // Project → Slack: session events (LISTEN 'polaris_event' + slow backfill) → formatted Slack posts
6
6
  // Slack → Project: advisor messages → injected into target session
7
7
 
8
8
  import { SocketModeClient } from "@slack/socket-mode";
9
9
  import { WebClient } from "@slack/web-api";
10
- import { createDb, getOrg, listProjects, getProjectEvents, getOrgEventsSince, getSession, createSession, pushEvent, setProjectSlackChannel, type Sql, type Org } from "../service/db";
10
+ import { createDb, getOrg, listProjects, getEventById, getOrgEventsSince, getSession, createSession, pushEvent, setProjectSlackChannel, type Sql, type Org } from "../service/db";
11
+ import { discoverBridgeOrgs } from "../bridge-discover-org";
11
12
  import { formatEventForSlack, toMrkdwn, THREAD_THRESHOLD } from "./format";
12
13
  import type { PolarisEvent } from "../types";
13
14
 
14
15
  // --- Channel management ---
15
16
 
16
- // In-memory cache: project name → Slack channel ID
17
- const channelCache = new Map<string, string>();
17
+ // Per-bridge in-memory cache: project name → Slack channel ID
18
+ // (instantiated per org so same-named projects in different orgs don't collide)
19
+ type ChannelCache = Map<string, string>;
18
20
 
19
- async function getOrCreateChannel(web: WebClient, sql: Sql, orgId: string, projectName: string): Promise<string> {
21
+ async function getOrCreateChannel(web: WebClient, sql: Sql, orgId: string, projectName: string, channelCache: ChannelCache): Promise<string> {
20
22
  // 1. Check in-memory cache
21
23
  const cached = channelCache.get(projectName);
22
24
  if (cached) return cached;
@@ -85,7 +87,7 @@ async function getOrCreateChannel(web: WebClient, sql: Sql, orgId: string, proje
85
87
 
86
88
  // --- Event → Slack posting ---
87
89
 
88
- async function postEventToSlack(web: WebClient, sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
90
+ async function postEventToSlack(web: WebClient, sql: Sql, orgId: string, event: PolarisEvent, channelCache: ChannelCache): Promise<void> {
89
91
  // Skip _system events (handled separately)
90
92
  if (event.project === "_system") return;
91
93
 
@@ -96,7 +98,7 @@ async function postEventToSlack(web: WebClient, sql: Sql, orgId: string, event:
96
98
  if (!msg) return;
97
99
 
98
100
  try {
99
- const channelId = await getOrCreateChannel(web, sql, orgId, event.project);
101
+ const channelId = await getOrCreateChannel(web, sql, orgId, event.project, channelCache);
100
102
  const isLong = msg.text.length > THREAD_THRESHOLD;
101
103
  // POLARIS_LONG_MSG controls how messages longer than THREAD_THRESHOLD are posted:
102
104
  // "snippet" (default) — 500-char preview + full content as expandable text snippet
@@ -169,7 +171,8 @@ async function handleSlackMessage(
169
171
  user: string;
170
172
  channel: string;
171
173
  ts: string;
172
- }
174
+ },
175
+ channelCache: ChannelCache
173
176
  ): Promise<void> {
174
177
  // Ignore bot's own messages
175
178
  if (event.user === botUserId) return;
@@ -284,23 +287,27 @@ async function handleSlackMessage(
284
287
 
285
288
  // --- Bridge startup ---
286
289
 
287
- export async function startBridge(opts: {
288
- databaseUrl?: string;
290
+ interface OrgBridge {
289
291
  orgId: string;
290
- apiBaseUrl?: string;
291
- }): Promise<{ stop: () => void }> {
292
- const sql = await createDb(opts.databaseUrl);
293
- const org = await getOrg(sql, opts.orgId);
294
- if (!org) throw new Error(`Org not found: ${opts.orgId}`);
295
- if (!org.slack_bot_token) throw new Error(`Org ${opts.orgId} has no Slack bot token`);
292
+ handleEvent: (event: PolarisEvent) => Promise<void>;
293
+ stop: () => void;
294
+ }
296
295
 
297
- const appToken = process.env.SLACK_APP_TOKEN;
298
- if (!appToken) throw new Error("SLACK_APP_TOKEN required for Socket Mode");
296
+ // One independent bridge per org (own WebClient, SocketModeClient, caches). Realtime
297
+ // delivery comes via handleEvent (fed from LISTEN); a slow 30s poll backfills any misses.
298
+ async function startOrgBridge(sql: Sql, org: Org): Promise<OrgBridge> {
299
+ if (!org.slack_bot_token) throw new Error(`Org ${org.id} has no Slack bot token`);
299
300
 
300
- const apiBaseUrl = opts.apiBaseUrl ?? process.env.POLARIS_API_URL ?? "http://localhost:4321";
301
+ // Socket Mode needs an app-level token (not stored in the orgs table). Per-org
302
+ // override via SLACK_APP_TOKEN_<team id>, falling back to the shared SLACK_APP_TOKEN.
303
+ const appToken =
304
+ (org.slack_team_id ? process.env[`SLACK_APP_TOKEN_${org.slack_team_id}`] : undefined) ??
305
+ process.env.SLACK_APP_TOKEN;
306
+ if (!appToken) throw new Error("SLACK_APP_TOKEN required for Socket Mode");
301
307
 
302
308
  const web = new WebClient(org.slack_bot_token);
303
309
  const socketMode = new SocketModeClient({ appToken });
310
+ const channelCache: ChannelCache = new Map();
304
311
 
305
312
  // Get bot user ID to filter own messages
306
313
  let botUserId = "";
@@ -322,11 +329,11 @@ export async function startBridge(opts: {
322
329
  // Handle channel rename system messages
323
330
  if (msg.subtype === "channel_name" && msg.channel && msg.name) {
324
331
  console.error(`[bridge] channel renamed: ${msg.channel} → ${msg.name}`);
325
- const projects = await listProjects(sql, opts.orgId);
332
+ const projects = await listProjects(sql, org.id);
326
333
  for (const proj of projects) {
327
334
  if (proj.slack_channel_id === msg.channel) {
328
335
  channelCache.set(proj.name, msg.channel);
329
- await setProjectSlackChannel(sql, opts.orgId, proj.name, msg.channel, msg.name);
336
+ await setProjectSlackChannel(sql, org.id, proj.name, msg.channel, msg.name);
330
337
  // Notify local daemon so status line updates immediately
331
338
  try {
332
339
  await fetch(`http://127.0.0.1:${process.env.POLARIS_DAEMON_PORT ?? 4322}/channel-update`, {
@@ -343,27 +350,33 @@ export async function startBridge(opts: {
343
350
 
344
351
  if (msg.subtype || !msg.channel || !msg.text || !msg.user) return;
345
352
  if (msg.user === botUserId) return;
346
- await handleSlackMessage(web, sql, opts.orgId, botUserId, msg as { text: string; user: string; channel: string; ts: string });
353
+ await handleSlackMessage(web, sql, org.id, botUserId, msg as { text: string; user: string; channel: string; ts: string }, channelCache);
347
354
  } catch (e) {
348
355
  console.error(`[bridge] message handler error:`, e);
349
356
  }
350
357
  });
351
358
 
352
- // Poll for new events directly from DB (bridge runs server-side)
359
+ // De-dupe between the realtime LISTEN path and the safety backfill poll
353
360
  const postedEventIds = new Set<string>();
361
+
362
+ async function handleEvent(event: PolarisEvent): Promise<void> {
363
+ if (event.project === "_system") return;
364
+ if (postedEventIds.has(event.id)) return;
365
+ postedEventIds.add(event.id);
366
+ await postEventToSlack(web, sql, org.id, event, channelCache);
367
+ }
368
+
369
+ // Safety backfill: slow poll catching anything the LISTEN path missed
354
370
  let lastPollTime = new Date().toISOString();
355
371
 
356
372
  async function pollEvents() {
357
373
  try {
358
374
  const since = lastPollTime;
359
- const events = await getOrgEventsSince(sql, opts.orgId, since);
375
+ const events = await getOrgEventsSince(sql, org.id, since);
360
376
  const now = new Date().toISOString();
361
377
 
362
378
  for (const event of events) {
363
- if (event.project === "_system") continue;
364
- if (postedEventIds.has(event.id)) continue;
365
- postedEventIds.add(event.id);
366
- await postEventToSlack(web, sql, opts.orgId, event);
379
+ await handleEvent(event);
367
380
  }
368
381
 
369
382
  lastPollTime = now;
@@ -372,7 +385,7 @@ export async function startBridge(opts: {
372
385
  }
373
386
  }
374
387
 
375
- const pollInterval = setInterval(pollEvents, 5000);
388
+ const pollInterval = setInterval(pollEvents, 30_000);
376
389
 
377
390
  // Start Socket Mode
378
391
  await socketMode.start();
@@ -380,9 +393,132 @@ export async function startBridge(opts: {
380
393
  console.error(`[bridge] Watching for messages in project channels`);
381
394
 
382
395
  return {
396
+ orgId: org.id,
397
+ handleEvent,
383
398
  stop: () => {
384
399
  clearInterval(pollInterval);
385
400
  socketMode.disconnect();
401
+ },
402
+ };
403
+ }
404
+
405
+ // Realtime backbone: one dedicated LISTEN('polaris_event') subscription. NOTIFY carries
406
+ // the event id only; fetch the full event via getEventById (carries org_id) and route it.
407
+ async function listenForEvents(
408
+ sql: Sql,
409
+ route: (event: PolarisEvent & { org_id: string }) => Promise<void>
410
+ ): Promise<() => Promise<void>> {
411
+ const { unlisten } = await sql.listen("polaris_event", (id) => {
412
+ void (async () => {
413
+ try {
414
+ const event = await getEventById(sql, id);
415
+ if (event) await route(event);
416
+ } catch (e) {
417
+ console.error("[bridge] LISTEN handler error:", e);
418
+ }
419
+ })();
420
+ });
421
+ return unlisten;
422
+ }
423
+
424
+ // Single-org bridge (back-compat: docker/bridge-entrypoint.sh passes one org id).
425
+ export async function startBridge(opts: {
426
+ databaseUrl?: string;
427
+ orgId: string;
428
+ apiBaseUrl?: string;
429
+ }): Promise<{ stop: () => void }> {
430
+ const sql = await createDb(opts.databaseUrl);
431
+ const org = await getOrg(sql, opts.orgId);
432
+ if (!org) throw new Error(`Org not found: ${opts.orgId}`);
433
+ if (!org.slack_bot_token) throw new Error(`Org ${opts.orgId} has no Slack bot token`);
434
+
435
+ const bridge = await startOrgBridge(sql, org);
436
+ const unlisten = await listenForEvents(sql, async (event) => {
437
+ if (event.org_id === org.id) await bridge.handleEvent(event);
438
+ });
439
+
440
+ return {
441
+ stop: () => {
442
+ void unlisten().catch(() => {});
443
+ bridge.stop();
444
+ sql.end();
445
+ },
446
+ };
447
+ }
448
+
449
+ // In-process guard: orgs this process is already bridging
450
+ const startedOrgIds = new Set<string>();
451
+
452
+ // Discover every Slack-connected org, start one bridge each. Double-start guarded by the
453
+ // in-process startedOrgIds set + a non-blocking Postgres advisory lock (pg_try_advisory_lock,
454
+ // held on a reserved connection) so two processes never bridge the same org.
455
+ export async function startAllBridges(opts: {
456
+ databaseUrl?: string;
457
+ apiBaseUrl?: string;
458
+ } = {}): Promise<{ stop: () => void }> {
459
+ const sql = await createDb(opts.databaseUrl);
460
+ const bridges = new Map<string, OrgBridge>();
461
+ const cleanups: Array<() => void> = [];
462
+
463
+ const orgIds = await discoverBridgeOrgs(sql);
464
+ if (orgIds.length === 0) {
465
+ console.error("[bridge] No Slack-connected orgs found");
466
+ }
467
+
468
+ for (const orgId of orgIds) {
469
+ if (startedOrgIds.has(orgId)) {
470
+ console.error(`[bridge] Org ${orgId} already bridged in this process, skipping`);
471
+ continue;
472
+ }
473
+
474
+ // Cross-process guard: advisory lock on a hash of the org id, held for the life of
475
+ // the bridge on a dedicated (reserved) connection.
476
+ const lock = await sql.reserve();
477
+ let locked = false;
478
+ try {
479
+ const [row] = await lock`SELECT pg_try_advisory_lock(hashtext('polaris_bridge'), hashtext(${orgId})) AS locked`;
480
+ locked = row?.locked === true;
481
+ } catch (e) {
482
+ console.error(`[bridge] Advisory lock check failed for org ${orgId}:`, e);
483
+ }
484
+ if (!locked) {
485
+ lock.release();
486
+ console.error(`[bridge] Org ${orgId} is already bridged by another process, skipping`);
487
+ continue;
488
+ }
489
+
490
+ try {
491
+ const org = await getOrg(sql, orgId);
492
+ if (!org?.slack_bot_token) throw new Error("org missing or has no Slack bot token");
493
+ const bridge = await startOrgBridge(sql, org);
494
+ startedOrgIds.add(orgId);
495
+ bridges.set(orgId, bridge);
496
+ cleanups.push(() => {
497
+ bridge.stop();
498
+ bridges.delete(orgId);
499
+ startedOrgIds.delete(orgId);
500
+ void lock`SELECT pg_advisory_unlock(hashtext('polaris_bridge'), hashtext(${orgId}))`
501
+ .then(() => lock.release(), () => lock.release());
502
+ });
503
+ } catch (e) {
504
+ console.error(`[bridge] Failed to start bridge for org ${orgId}:`, e);
505
+ try { await lock`SELECT pg_advisory_unlock(hashtext('polaris_bridge'), hashtext(${orgId}))`; } catch { /* lock dies with the session */ }
506
+ lock.release();
507
+ }
508
+ }
509
+
510
+ // ONE LISTEN subscription fans events out to the right org's bridge by org_id
511
+ const unlisten = await listenForEvents(sql, async (event) => {
512
+ const bridge = bridges.get(event.org_id);
513
+ if (bridge) await bridge.handleEvent(event);
514
+ });
515
+
516
+ console.error(`[bridge] Multi-org bridge running for ${bridges.size} org(s)`);
517
+
518
+ return {
519
+ stop: () => {
520
+ void unlisten().catch(() => {});
521
+ for (const cleanup of cleanups) cleanup();
386
522
  sql.end();
387
523
  },
388
524
  };
@@ -391,9 +527,11 @@ export async function startBridge(opts: {
391
527
  // --- Run if executed directly ---
392
528
  if (import.meta.main) {
393
529
  const orgId = process.argv[2];
394
- if (!orgId) {
395
- console.error("Usage: bun run src/slack/bridge.ts <org-id>");
396
- process.exit(1);
530
+ if (orgId) {
531
+ // Back-compat: explicit org id → single-org bridge (docker/bridge-entrypoint.sh)
532
+ await startBridge({ orgId });
533
+ } else {
534
+ // No args → bridge every Slack-connected org
535
+ await startAllBridges();
397
536
  }
398
- await startBridge({ orgId });
399
537
  }
package/src/types.ts CHANGED
@@ -99,6 +99,9 @@ export const Project = z.object({
99
99
  name: z.string().min(1),
100
100
  slack_channel_id: z.string().nullable().optional(),
101
101
  slack_channel_name: z.string().nullable().optional(),
102
+ // 'org' (default, visible to whole org) or 'members' (restricted to project_members).
103
+ // Optional for back-compat with rows/payloads that predate the visibility migration.
104
+ visibility: z.string().optional(),
102
105
  created_at: z.string().datetime(),
103
106
  });
104
107
 
@@ -108,7 +111,26 @@ export const Session = z.object({
108
111
  name: z.string().min(1),
109
112
  project: z.string().min(1),
110
113
  driver: ParticipantId.nullable(),
114
+ label: z.string().nullable().optional(),
111
115
  created_at: z.string().datetime(),
112
116
  });
113
117
 
114
118
  export type Session = z.infer<typeof Session>;
119
+
120
+ // --- Annotations (curation: stars, tags, decisions) ---
121
+
122
+ export const AnnotationKind = z.enum(["star", "tag", "decision"]);
123
+ export type AnnotationKind = z.infer<typeof AnnotationKind>;
124
+
125
+ export const Annotation = z.object({
126
+ id: z.string().uuid(),
127
+ event_id: z.string().uuid().nullable(),
128
+ project: z.string().min(1),
129
+ session: z.string().min(1),
130
+ participant_id: z.string().nullable(),
131
+ kind: AnnotationKind,
132
+ value: z.string().nullable(),
133
+ created_at: z.string().datetime(),
134
+ });
135
+
136
+ export type Annotation = z.infer<typeof Annotation>;