@lightupai/polaris 0.0.77 → 0.0.78
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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
package/src/slack/bridge.ts
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
17
|
-
|
|
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
|
-
|
|
288
|
-
databaseUrl?: string;
|
|
290
|
+
interface OrgBridge {
|
|
289
291
|
orgId: string;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
395
|
-
|
|
396
|
-
|
|
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>;
|