@lightupai/polaris 0.0.76 → 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/web/views.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { nav, slackIcon, type NavOpts } from "./layout";
|
|
5
5
|
import type { SessionFixture, ProjectFixture, DeviceFixture } from "./fixtures";
|
|
6
|
+
import type { Annotation } from "../types";
|
|
6
7
|
|
|
7
8
|
interface TeamMember {
|
|
8
9
|
name: string;
|
|
@@ -24,10 +25,22 @@ interface ViewContext {
|
|
|
24
25
|
dailyPrompts?: Array<{ date: string; sender: string; count: number }>;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function navOpts(ctx:
|
|
28
|
+
function navOpts(ctx: { userName: string; orgName: string; email: string; plan?: string }): NavOpts {
|
|
28
29
|
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email, plan: ctx.plan, banner: bannerForCtx(ctx) };
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// Minimal context for pages that don't need the full dashboard state (transcript, search).
|
|
33
|
+
export interface PageContext {
|
|
34
|
+
token: string;
|
|
35
|
+
userName: string;
|
|
36
|
+
orgName: string;
|
|
37
|
+
email: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeHtml(text: string): string {
|
|
41
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
// --- Copyable code block ---
|
|
32
45
|
|
|
33
46
|
function copyBlock(text: string): string {
|
|
@@ -47,6 +60,8 @@ function sectionHeader(title: string): string {
|
|
|
47
60
|
return `<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">${title}</h2>`;
|
|
48
61
|
}
|
|
49
62
|
|
|
63
|
+
const formInputClass = "border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-polaris-300 focus:border-polaris-300";
|
|
64
|
+
|
|
50
65
|
function statusBadge(label: string, done: boolean): string {
|
|
51
66
|
return done
|
|
52
67
|
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
@@ -58,7 +73,7 @@ function statusBadge(label: string, done: boolean): string {
|
|
|
58
73
|
|
|
59
74
|
// --- Banner helpers ---
|
|
60
75
|
|
|
61
|
-
function bannerForCtx(ctx:
|
|
76
|
+
function bannerForCtx(ctx: { plan?: string }): { message: string; style: "info" | "success" | "warning" } | undefined {
|
|
62
77
|
if (ctx.plan && ctx.plan !== "free") {
|
|
63
78
|
const label = ctx.plan.charAt(0).toUpperCase() + ctx.plan.slice(1);
|
|
64
79
|
return { message: `<strong>${label} plan</strong> — we'll reach out shortly to get you set up. Full access in the meantime.`, style: "info" };
|
|
@@ -368,7 +383,7 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
368
383
|
</div>
|
|
369
384
|
</details>
|
|
370
385
|
<div class="space-y-4">
|
|
371
|
-
${projects.map((p) => renderProjectCard(p, ctx.userName)).join("")}
|
|
386
|
+
${projects.map((p) => renderProjectCard(p, ctx.userName, ctx.token)).join("")}
|
|
372
387
|
</div>
|
|
373
388
|
</div>`;
|
|
374
389
|
}
|
|
@@ -392,7 +407,7 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
392
407
|
</div>`);
|
|
393
408
|
}
|
|
394
409
|
|
|
395
|
-
function renderProjectCard(project: ProjectFixture, userName: string): string {
|
|
410
|
+
function renderProjectCard(project: ProjectFixture, userName: string, token: string): string {
|
|
396
411
|
const sessionCount = project.sessions.length;
|
|
397
412
|
const participantId = `user:${userName.toLowerCase().replace(/\s+/g, ".")}`;
|
|
398
413
|
|
|
@@ -403,7 +418,10 @@ function renderProjectCard(project: ProjectFixture, userName: string): string {
|
|
|
403
418
|
<p class="text-sm font-semibold text-gray-900">${project.name}</p>
|
|
404
419
|
${project.slackChannel ? `<span class="text-xs text-gray-400">${project.slackChannel}</span>` : ""}
|
|
405
420
|
</div>
|
|
406
|
-
<
|
|
421
|
+
<div class="flex items-center gap-3">
|
|
422
|
+
<span class="text-xs text-gray-400">${sessionCount} session${sessionCount !== 1 ? "s" : ""}</span>
|
|
423
|
+
<a href="/projects/${encodeURIComponent(project.name)}/settings?token=${token}" class="text-xs font-medium text-gray-400 hover:text-polaris-700">Settings</a>
|
|
424
|
+
</div>
|
|
407
425
|
</div>
|
|
408
426
|
<div class="divide-y divide-gray-50">
|
|
409
427
|
${project.sessions.map((s) => {
|
|
@@ -419,16 +437,18 @@ function renderProjectCard(project: ProjectFixture, userName: string): string {
|
|
|
419
437
|
const promptLabel = s.eventCount > 0
|
|
420
438
|
? `<span class="text-xs text-gray-400">${s.eventCount} prompt${s.eventCount !== 1 ? "s" : ""}</span>`
|
|
421
439
|
: '';
|
|
440
|
+
const transcriptHref = `/sessions/${encodeURIComponent(project.name)}/${encodeURIComponent(s.name)}?token=${token}`;
|
|
422
441
|
return `
|
|
423
442
|
<div class="px-4 py-3 flex items-center justify-between">
|
|
424
443
|
<div class="flex items-center gap-2">
|
|
425
444
|
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
|
426
|
-
<
|
|
445
|
+
<a href="${transcriptHref}" class="text-sm text-gray-700 hover:text-polaris-700 hover:underline">${s.name}</a>
|
|
427
446
|
${roleBadge}
|
|
428
447
|
</div>
|
|
429
448
|
<div class="flex items-center gap-3">
|
|
430
449
|
${driverLabel}
|
|
431
450
|
${promptLabel}
|
|
451
|
+
<a href="${transcriptHref}" class="text-xs font-medium text-polaris-700 hover:text-polaris-800">Transcript</a>
|
|
432
452
|
</div>
|
|
433
453
|
</div>`;
|
|
434
454
|
}).join("")}
|
|
@@ -542,3 +562,397 @@ export function renderProfileView(ctx: ViewContext, participantId: string): stri
|
|
|
542
562
|
</div>
|
|
543
563
|
</div>`;
|
|
544
564
|
}
|
|
565
|
+
|
|
566
|
+
// --- Session transcript view ---
|
|
567
|
+
// Events arrive from the API newest-first ({ events, nextCursor }); we render ascending.
|
|
568
|
+
|
|
569
|
+
export interface TranscriptEvent {
|
|
570
|
+
id: string;
|
|
571
|
+
project: string;
|
|
572
|
+
session: string;
|
|
573
|
+
timestamp: string;
|
|
574
|
+
source: string;
|
|
575
|
+
sender: string;
|
|
576
|
+
payload: Record<string, unknown>;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
interface EventPayload {
|
|
580
|
+
hook_event_name?: string;
|
|
581
|
+
prompt?: string;
|
|
582
|
+
stop_response?: string;
|
|
583
|
+
last_assistant_message?: string;
|
|
584
|
+
tool_name?: string;
|
|
585
|
+
tool_input?: unknown;
|
|
586
|
+
tool_result?: unknown;
|
|
587
|
+
content?: string;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function eventMeta(e: TranscriptEvent): string {
|
|
591
|
+
return `<span class="text-xs text-gray-400">${escapeHtml(e.sender)} · ${new Date(e.timestamp).toLocaleString()}</span>`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function messageCard(badge: string, badgeClass: string, e: TranscriptEvent, text: string, cardClass = "bg-white border border-gray-200"): string {
|
|
595
|
+
return `
|
|
596
|
+
<div class="${cardClass} rounded-lg px-4 py-3">
|
|
597
|
+
<div class="flex items-baseline gap-2">
|
|
598
|
+
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${badgeClass}">${badge}</span>
|
|
599
|
+
${eventMeta(e)}
|
|
600
|
+
</div>
|
|
601
|
+
<p class="mt-1.5 text-sm text-gray-800 whitespace-pre-wrap">${escapeHtml(text)}</p>
|
|
602
|
+
</div>`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderTranscriptItem(e: TranscriptEvent): string {
|
|
606
|
+
const p = e.payload as EventPayload;
|
|
607
|
+
|
|
608
|
+
if (e.source === "inject") {
|
|
609
|
+
return `
|
|
610
|
+
<div class="border-l-4 border-amber-400 bg-amber-50 rounded-r-lg px-4 py-3">
|
|
611
|
+
<div class="flex items-baseline gap-2">
|
|
612
|
+
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">Inject</span>
|
|
613
|
+
${eventMeta(e)}
|
|
614
|
+
</div>
|
|
615
|
+
<p class="mt-1.5 text-sm text-gray-800 whitespace-pre-wrap">[${escapeHtml(e.sender)}] ${escapeHtml(p.content ?? "")}</p>
|
|
616
|
+
</div>`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (e.source === "reply") {
|
|
620
|
+
return messageCard("Reply", "bg-gray-100 text-gray-600", e, p.content ?? "");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (typeof p.prompt === "string") {
|
|
624
|
+
return messageCard("Prompt", "bg-polaris-100 text-polaris-800", e, p.prompt);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (typeof p.tool_name === "string") {
|
|
628
|
+
const detail = p.hook_event_name === "PostToolUse" ? p.tool_result : p.tool_input;
|
|
629
|
+
let detailText = "";
|
|
630
|
+
try { detailText = JSON.stringify(detail, null, 2) ?? ""; } catch { detailText = String(detail); }
|
|
631
|
+
return `
|
|
632
|
+
<details class="px-1">
|
|
633
|
+
<summary class="flex items-baseline gap-2 py-1 text-xs text-gray-400 hover:text-gray-600 cursor-pointer select-none">
|
|
634
|
+
<span class="font-mono text-gray-500">${escapeHtml(p.tool_name)}</span>
|
|
635
|
+
<span>${p.hook_event_name === "PostToolUse" ? "result" : "tool call"}</span>
|
|
636
|
+
<span class="ml-auto">${eventMeta(e)}</span>
|
|
637
|
+
</summary>
|
|
638
|
+
${detailText ? `<pre class="mt-1 mb-2 bg-gray-50 border border-gray-200 rounded px-3 py-2 text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-64 overflow-y-auto">${escapeHtml(detailText)}</pre>` : ""}
|
|
639
|
+
</details>`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const response = typeof p.stop_response === "string" && p.stop_response ? p.stop_response : p.last_assistant_message;
|
|
643
|
+
if (typeof response === "string" && response) {
|
|
644
|
+
return messageCard("Agent", "bg-green-100 text-green-800", e, response, "bg-white border border-gray-200");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return "";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// --- Annotation controls (star / tag / decision curation) ---
|
|
651
|
+
|
|
652
|
+
// Short excerpt of the event's main text, used as the default note when marking a decision.
|
|
653
|
+
function eventExcerpt(e: TranscriptEvent): string {
|
|
654
|
+
const p = e.payload as EventPayload;
|
|
655
|
+
const text =
|
|
656
|
+
typeof p.prompt === "string" ? p.prompt
|
|
657
|
+
: typeof p.content === "string" ? p.content
|
|
658
|
+
: typeof p.stop_response === "string" && p.stop_response ? p.stop_response
|
|
659
|
+
: typeof p.last_assistant_message === "string" ? p.last_assistant_message
|
|
660
|
+
: "";
|
|
661
|
+
return text.slice(0, 200);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function annotationForm(action: string, fields: Record<string, string>, button: string): string {
|
|
665
|
+
const inputs = Object.entries(fields)
|
|
666
|
+
.map(([name, value]) => `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`)
|
|
667
|
+
.join("");
|
|
668
|
+
return `<form method="POST" action="${action}" class="inline-flex">${inputs}${button}</form>`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function renderAnnotationControls(e: TranscriptEvent, annotations: Annotation[], base: string, token: string): string {
|
|
672
|
+
const own = annotations.filter((a) => a.event_id === e.id);
|
|
673
|
+
const star = own.find((a) => a.kind === "star");
|
|
674
|
+
const tags = own.filter((a) => a.kind === "tag");
|
|
675
|
+
const decision = own.find((a) => a.kind === "decision");
|
|
676
|
+
const addAction = `${base}/annotations?token=${token}`;
|
|
677
|
+
const deleteAction = (id: string) => `${base}/annotations/${encodeURIComponent(id)}/delete?token=${token}`;
|
|
678
|
+
|
|
679
|
+
const starControl = star
|
|
680
|
+
? annotationForm(deleteAction(star.id), {}, `<button type="submit" class="text-xs font-medium text-amber-500 hover:text-amber-600" title="Remove star">★ Starred</button>`)
|
|
681
|
+
: annotationForm(addAction, { event_id: e.id, kind: "star" }, `<button type="submit" class="text-xs text-gray-400 hover:text-amber-600" title="Star this event">☆ Star</button>`);
|
|
682
|
+
|
|
683
|
+
const tagChips = tags
|
|
684
|
+
.map((t) =>
|
|
685
|
+
annotationForm(deleteAction(t.id), {}, `<button type="submit" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800 hover:bg-polaris-200" title="Remove tag">#${escapeHtml(t.value ?? "")} <span class="text-polaris-400">×</span></button>`)
|
|
686
|
+
)
|
|
687
|
+
.join("");
|
|
688
|
+
|
|
689
|
+
const tagAdder = `
|
|
690
|
+
<details class="inline-block">
|
|
691
|
+
<summary class="text-xs text-gray-400 hover:text-polaris-700 cursor-pointer select-none list-none">+ Tag</summary>
|
|
692
|
+
<form method="POST" action="${addAction}" class="inline-flex items-center gap-1 mt-1">
|
|
693
|
+
<input type="hidden" name="event_id" value="${e.id}">
|
|
694
|
+
<input type="hidden" name="kind" value="tag">
|
|
695
|
+
<input name="value" required placeholder="tag-name" class="w-28 border border-gray-200 rounded px-2 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-polaris-300">
|
|
696
|
+
<button type="submit" class="px-2 py-0.5 bg-polaris-700 text-white text-xs font-medium rounded hover:bg-polaris-800">Add</button>
|
|
697
|
+
</form>
|
|
698
|
+
</details>`;
|
|
699
|
+
|
|
700
|
+
const decisionControl = decision
|
|
701
|
+
? annotationForm(deleteAction(decision.id), {}, `<button type="submit" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200" title="Unmark decision">⚑ Decision <span class="text-purple-400">×</span></button>`)
|
|
702
|
+
: annotationForm(addAction, { event_id: e.id, kind: "decision", value: eventExcerpt(e) }, `<button type="submit" class="text-xs text-gray-400 hover:text-purple-700" title="Mark as decision">⚑ Decision</button>`);
|
|
703
|
+
|
|
704
|
+
return `
|
|
705
|
+
<div class="flex flex-wrap items-center gap-2 mt-1 px-1">
|
|
706
|
+
${starControl}
|
|
707
|
+
${tagChips}
|
|
708
|
+
${tagAdder}
|
|
709
|
+
${decisionControl}
|
|
710
|
+
</div>`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function renderTranscriptView(
|
|
714
|
+
ctx: PageContext,
|
|
715
|
+
project: string,
|
|
716
|
+
session: string,
|
|
717
|
+
events: TranscriptEvent[],
|
|
718
|
+
nextCursor: string | null,
|
|
719
|
+
before?: string,
|
|
720
|
+
annotations: Annotation[] = []
|
|
721
|
+
): string {
|
|
722
|
+
const base = `/sessions/${encodeURIComponent(project)}/${encodeURIComponent(session)}`;
|
|
723
|
+
const items = [...events]
|
|
724
|
+
.reverse()
|
|
725
|
+
.map((e) => {
|
|
726
|
+
const html = renderTranscriptItem(e);
|
|
727
|
+
return html ? `<div>${html}${renderAnnotationControls(e, annotations, base, ctx.token)}</div>` : "";
|
|
728
|
+
})
|
|
729
|
+
.filter((html) => html !== "")
|
|
730
|
+
.join("\n");
|
|
731
|
+
|
|
732
|
+
// Session-level annotations (no event_id) render as badges under the header.
|
|
733
|
+
const sessionBadges = annotations
|
|
734
|
+
.filter((a) => a.event_id === null)
|
|
735
|
+
.map((a) =>
|
|
736
|
+
a.kind === "tag"
|
|
737
|
+
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">#${escapeHtml(a.value ?? "")}</span>`
|
|
738
|
+
: a.kind === "decision"
|
|
739
|
+
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">⚑ ${escapeHtml(a.value ?? "Decision")}</span>`
|
|
740
|
+
: `<span class="text-xs text-amber-500">★</span>`
|
|
741
|
+
)
|
|
742
|
+
.join("");
|
|
743
|
+
|
|
744
|
+
const pagingLinks = [
|
|
745
|
+
before ? `<a href="${base}?token=${ctx.token}" class="text-xs font-medium text-polaris-700 hover:text-polaris-800">Back to latest</a>` : "",
|
|
746
|
+
nextCursor ? `<a href="${base}?token=${ctx.token}&before=${encodeURIComponent(nextCursor)}" class="text-xs font-medium text-polaris-700 hover:text-polaris-800">Load older</a>` : "",
|
|
747
|
+
].filter(Boolean).join("");
|
|
748
|
+
|
|
749
|
+
return `
|
|
750
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
751
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 pb-16 space-y-10">
|
|
752
|
+
<div>
|
|
753
|
+
${sectionHeader("Session transcript")}
|
|
754
|
+
<div class="flex items-baseline justify-between mb-4">
|
|
755
|
+
<h1 class="text-2xl font-bold text-gray-900">${escapeHtml(session)}</h1>
|
|
756
|
+
<span class="text-sm text-gray-400 font-mono">${escapeHtml(project)}</span>
|
|
757
|
+
</div>
|
|
758
|
+
${sessionBadges ? `<div class="flex flex-wrap items-center gap-2 mb-3">${sessionBadges}</div>` : ""}
|
|
759
|
+
${pagingLinks ? `<div class="flex items-center gap-4 mb-3">${pagingLinks}</div>` : ""}
|
|
760
|
+
${items
|
|
761
|
+
? `<div class="space-y-3">${items}</div>`
|
|
762
|
+
: `<div class="bg-white border border-gray-200 rounded-lg p-5 text-sm text-gray-500">No events captured for this session yet.</div>`}
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div>
|
|
766
|
+
${sectionHeader("Inject guidance")}
|
|
767
|
+
<form method="POST" action="${base}/inject?token=${ctx.token}" class="bg-white border border-gray-200 rounded-lg p-5">
|
|
768
|
+
<p class="text-sm text-gray-500 mb-3">Send a note to this session's agent. It is delivered with the next prompt.</p>
|
|
769
|
+
<textarea name="content" rows="3" required placeholder="e.g. Use RS256, not HS256 — we need asymmetric keys" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-polaris-300 focus:border-polaris-300"></textarea>
|
|
770
|
+
<button type="submit" class="mt-3 px-4 py-2 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Inject</button>
|
|
771
|
+
</form>
|
|
772
|
+
</div>
|
|
773
|
+
</div>`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// --- Search view ---
|
|
777
|
+
|
|
778
|
+
export interface SearchQuery {
|
|
779
|
+
q: string;
|
|
780
|
+
project: string;
|
|
781
|
+
sender: string;
|
|
782
|
+
source: string;
|
|
783
|
+
tag: string;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export interface SearchResult {
|
|
787
|
+
event: TranscriptEvent;
|
|
788
|
+
snippet: string;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ts_headline marks matches with <b>...</b>; escape everything else, then restore the highlights.
|
|
792
|
+
function snippetHtml(snippet: string): string {
|
|
793
|
+
return escapeHtml(snippet)
|
|
794
|
+
.replace(/<b>/g, '<b class="font-semibold text-polaris-800 bg-polaris-100 rounded px-0.5">')
|
|
795
|
+
.replace(/<\/b>/g, "</b>");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export function renderSearchView(ctx: PageContext, query: SearchQuery, results: SearchResult[] | null, searchError?: string): string {
|
|
799
|
+
const inputClass = "border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-polaris-300 focus:border-polaris-300";
|
|
800
|
+
const sourceOptions = ["", "hook", "inject", "reply"]
|
|
801
|
+
.map((s) => `<option value="${s}"${s === query.source ? " selected" : ""}>${s === "" ? "Any source" : s}</option>`)
|
|
802
|
+
.join("");
|
|
803
|
+
|
|
804
|
+
let resultsSection = "";
|
|
805
|
+
if (searchError) {
|
|
806
|
+
resultsSection = `<div class="bg-white border border-red-200 rounded-lg p-5 text-sm text-red-700">${escapeHtml(searchError)}</div>`;
|
|
807
|
+
} else if (results !== null) {
|
|
808
|
+
const resultItems = results.map(({ event, snippet }) => {
|
|
809
|
+
const href = `/sessions/${encodeURIComponent(event.project)}/${encodeURIComponent(event.session)}?token=${ctx.token}`;
|
|
810
|
+
return `
|
|
811
|
+
<a href="${href}" class="block bg-white border border-gray-200 rounded-lg p-4 hover:border-polaris-300 hover:shadow-sm transition">
|
|
812
|
+
<div class="flex items-baseline gap-2 text-xs text-gray-400">
|
|
813
|
+
<span class="font-medium text-gray-700">${escapeHtml(event.sender)}</span>
|
|
814
|
+
<span class="font-mono">${escapeHtml(event.project)}/${escapeHtml(event.session)}</span>
|
|
815
|
+
<span class="ml-auto">${new Date(event.timestamp).toLocaleString()}</span>
|
|
816
|
+
</div>
|
|
817
|
+
<p class="mt-1.5 text-sm text-gray-700">${snippetHtml(snippet)}</p>
|
|
818
|
+
</a>`;
|
|
819
|
+
}).join("");
|
|
820
|
+
resultsSection = results.length
|
|
821
|
+
? `<div>
|
|
822
|
+
<p class="text-xs text-gray-400 mb-3">${results.length} result${results.length !== 1 ? "s" : ""} for “${escapeHtml(query.q)}”</p>
|
|
823
|
+
<div class="space-y-3">${resultItems}</div>
|
|
824
|
+
</div>`
|
|
825
|
+
: `<div class="bg-white border border-gray-200 rounded-lg p-5 text-sm text-gray-500">No results for “${escapeHtml(query.q)}”.</div>`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return `
|
|
829
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
830
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 pb-16 space-y-6">
|
|
831
|
+
<div>
|
|
832
|
+
${sectionHeader("Search")}
|
|
833
|
+
<form method="GET" action="/search" class="bg-white border border-gray-200 rounded-lg p-5 space-y-3">
|
|
834
|
+
<input type="hidden" name="token" value="${ctx.token}">
|
|
835
|
+
<div class="flex gap-2">
|
|
836
|
+
<input name="q" value="${escapeHtml(query.q)}" placeholder="Search prompts, responses, and messages" class="flex-1 ${inputClass}">
|
|
837
|
+
<button type="submit" class="px-4 py-2 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Search</button>
|
|
838
|
+
</div>
|
|
839
|
+
<div class="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
|
840
|
+
<input name="project" value="${escapeHtml(query.project)}" placeholder="Project" class="${inputClass}">
|
|
841
|
+
<input name="sender" value="${escapeHtml(query.sender)}" placeholder="Sender (e.g. user:priya)" class="${inputClass}">
|
|
842
|
+
<select name="source" class="bg-white ${inputClass}">${sourceOptions}</select>
|
|
843
|
+
<input name="tag" value="${escapeHtml(query.tag)}" placeholder="Tag" class="${inputClass}">
|
|
844
|
+
</div>
|
|
845
|
+
</form>
|
|
846
|
+
</div>
|
|
847
|
+
${resultsSection}
|
|
848
|
+
</div>`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// --- Decisions view ---
|
|
852
|
+
// Org-wide list of 'decision' annotations, each linking back to its transcript.
|
|
853
|
+
|
|
854
|
+
export function renderDecisionsView(ctx: PageContext, projectFilter: string, decisions: Annotation[] | null, error?: string): string {
|
|
855
|
+
let listSection = "";
|
|
856
|
+
if (error) {
|
|
857
|
+
listSection = `<div class="bg-white border border-red-200 rounded-lg p-5 text-sm text-red-700">${escapeHtml(error)}</div>`;
|
|
858
|
+
} else if (decisions !== null) {
|
|
859
|
+
const items = decisions.map((d) => {
|
|
860
|
+
const href = `/sessions/${encodeURIComponent(d.project)}/${encodeURIComponent(d.session)}?token=${ctx.token}`;
|
|
861
|
+
return `
|
|
862
|
+
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
|
863
|
+
<div class="flex items-baseline gap-2 text-xs text-gray-400">
|
|
864
|
+
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">⚑ Decision</span>
|
|
865
|
+
${d.participant_id ? `<span class="font-medium text-gray-700">${escapeHtml(d.participant_id)}</span>` : ""}
|
|
866
|
+
<span class="font-mono">${escapeHtml(d.project)}/${escapeHtml(d.session)}</span>
|
|
867
|
+
<span class="ml-auto">${new Date(d.created_at).toLocaleString()}</span>
|
|
868
|
+
</div>
|
|
869
|
+
<p class="mt-1.5 text-sm text-gray-700 whitespace-pre-wrap">${d.value ? escapeHtml(d.value) : '<span class="text-gray-400">No note</span>'}</p>
|
|
870
|
+
<a href="${href}" class="mt-2 inline-block text-xs font-medium text-polaris-700 hover:text-polaris-800">View transcript</a>
|
|
871
|
+
</div>`;
|
|
872
|
+
}).join("");
|
|
873
|
+
listSection = decisions.length
|
|
874
|
+
? `<div class="space-y-3">${items}</div>`
|
|
875
|
+
: `<div class="bg-white border border-gray-200 rounded-lg p-5 text-sm text-gray-500">No decisions recorded yet. Mark transcript events as decisions to collect them here.</div>`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return `
|
|
879
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
880
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 pb-16 space-y-6">
|
|
881
|
+
<div>
|
|
882
|
+
${sectionHeader("Decisions")}
|
|
883
|
+
<form method="GET" action="/decisions" class="bg-white border border-gray-200 rounded-lg p-5 flex gap-2">
|
|
884
|
+
<input type="hidden" name="token" value="${ctx.token}">
|
|
885
|
+
<input name="project" value="${escapeHtml(projectFilter)}" placeholder="Filter by project" class="flex-1 ${formInputClass}">
|
|
886
|
+
<button type="submit" class="px-4 py-2 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Filter</button>
|
|
887
|
+
</form>
|
|
888
|
+
</div>
|
|
889
|
+
${listSection}
|
|
890
|
+
</div>`;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// --- Project settings view ---
|
|
894
|
+
// Visibility toggle ('org' = everyone, 'members' = restricted) and member management.
|
|
895
|
+
|
|
896
|
+
export interface ProjectMemberView {
|
|
897
|
+
participant_id: string;
|
|
898
|
+
role: string | null;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export function renderProjectSettingsView(ctx: PageContext, project: string, visibility: string, members: ProjectMemberView[], membersError?: string): string {
|
|
902
|
+
const base = `/projects/${encodeURIComponent(project)}`;
|
|
903
|
+
const isMembers = visibility === "members";
|
|
904
|
+
|
|
905
|
+
const memberRows = members.map((m) => `
|
|
906
|
+
<div class="px-4 py-3 flex items-center justify-between">
|
|
907
|
+
<div class="flex items-center gap-2">
|
|
908
|
+
<p class="text-sm font-mono text-gray-700">${escapeHtml(m.participant_id)}</p>
|
|
909
|
+
${m.role ? `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">${escapeHtml(m.role)}</span>` : ""}
|
|
910
|
+
</div>
|
|
911
|
+
<form method="POST" action="${base}/members/${encodeURIComponent(m.participant_id)}/delete?token=${ctx.token}">
|
|
912
|
+
<button type="submit" class="text-xs font-medium text-red-600 hover:text-red-700">Remove</button>
|
|
913
|
+
</form>
|
|
914
|
+
</div>`).join("");
|
|
915
|
+
|
|
916
|
+
return `
|
|
917
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
918
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 pb-16 space-y-10">
|
|
919
|
+
<div>
|
|
920
|
+
${sectionHeader("Project settings")}
|
|
921
|
+
<div class="flex items-baseline justify-between mb-4">
|
|
922
|
+
<h1 class="text-2xl font-bold text-gray-900">${escapeHtml(project)}</h1>
|
|
923
|
+
<a href="/dashboard?token=${ctx.token}" class="text-xs font-medium text-polaris-700 hover:text-polaris-800">Back to dashboard</a>
|
|
924
|
+
</div>
|
|
925
|
+
<form method="POST" action="${base}/visibility?token=${ctx.token}" class="bg-white border border-gray-200 rounded-lg p-5">
|
|
926
|
+
<p class="text-sm font-semibold text-gray-900">Visibility</p>
|
|
927
|
+
<p class="text-sm text-gray-500 mt-1">Who can view this project's sessions and transcripts.</p>
|
|
928
|
+
<div class="mt-3 space-y-2">
|
|
929
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
930
|
+
<input type="radio" name="visibility" value="org"${isMembers ? "" : " checked"}>
|
|
931
|
+
Everyone in the organization
|
|
932
|
+
</label>
|
|
933
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
934
|
+
<input type="radio" name="visibility" value="members"${isMembers ? " checked" : ""}>
|
|
935
|
+
Members only
|
|
936
|
+
</label>
|
|
937
|
+
</div>
|
|
938
|
+
<button type="submit" class="mt-4 px-4 py-2 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Save visibility</button>
|
|
939
|
+
</form>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<div>
|
|
943
|
+
${sectionHeader("Members")}
|
|
944
|
+
${membersError ? `<div class="bg-white border border-red-200 rounded-lg p-5 text-sm text-red-700 mb-3">${escapeHtml(membersError)}</div>` : ""}
|
|
945
|
+
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
|
|
946
|
+
${memberRows || `<div class="px-4 py-3 text-sm text-gray-500">No members yet.${isMembers ? "" : " Membership only applies when visibility is set to members only."}</div>`}
|
|
947
|
+
</div>
|
|
948
|
+
<form method="POST" action="${base}/members?token=${ctx.token}" class="mt-3 bg-white border border-gray-200 rounded-lg p-5">
|
|
949
|
+
<p class="text-sm font-semibold text-gray-900">Add member</p>
|
|
950
|
+
<div class="mt-3 flex flex-col sm:flex-row gap-2">
|
|
951
|
+
<input name="participant_id" required placeholder="user:priya" class="flex-1 ${formInputClass}">
|
|
952
|
+
<input name="role" placeholder="Role (optional)" class="sm:w-40 ${formInputClass}">
|
|
953
|
+
<button type="submit" class="px-4 py-2 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Add</button>
|
|
954
|
+
</div>
|
|
955
|
+
</form>
|
|
956
|
+
</div>
|
|
957
|
+
</div>`;
|
|
958
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createDb,
|
|
4
|
+
createOrg,
|
|
5
|
+
createProject,
|
|
6
|
+
getProject,
|
|
7
|
+
setProjectVisibility,
|
|
8
|
+
addProjectMember,
|
|
9
|
+
removeProjectMember,
|
|
10
|
+
listProjectMembers,
|
|
11
|
+
userCanAccessProject,
|
|
12
|
+
type Sql,
|
|
13
|
+
} from "../src/service/db";
|
|
14
|
+
|
|
15
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
16
|
+
|
|
17
|
+
// Unique ids so this file is self-contained against a live Postgres.
|
|
18
|
+
// (project_members rows survive resetTestData, so unique ids matter.)
|
|
19
|
+
const RUN = crypto.randomUUID().slice(0, 8);
|
|
20
|
+
const ORG = `acl-org-${RUN}`;
|
|
21
|
+
const OPEN_PROJECT = `pj-acl-open-${RUN}`;
|
|
22
|
+
const LOCKED_PROJECT = `pj-acl-locked-${RUN}`;
|
|
23
|
+
|
|
24
|
+
const MEMBER = `user:member-${RUN}`;
|
|
25
|
+
const OUTSIDER = `user:outsider-${RUN}`;
|
|
26
|
+
|
|
27
|
+
let sql: Sql;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
// Drop and recreate via createDb (mirrors tests/db.test.ts) so the schema —
|
|
31
|
+
// including the visibility migration and project_members — matches db.ts exactly.
|
|
32
|
+
sql = await createDb(DATABASE_URL);
|
|
33
|
+
await sql`DROP TABLE IF EXISTS plan_changes`;
|
|
34
|
+
await sql`DROP TABLE IF EXISTS events`;
|
|
35
|
+
await sql`DROP TABLE IF EXISTS sessions`;
|
|
36
|
+
await sql`DROP TABLE IF EXISTS projects`;
|
|
37
|
+
await sql`DROP TABLE IF EXISTS users`;
|
|
38
|
+
await sql`DROP TABLE IF EXISTS orgs`;
|
|
39
|
+
await sql.end();
|
|
40
|
+
sql = await createDb(DATABASE_URL);
|
|
41
|
+
await createOrg(sql, ORG, "ACL Org");
|
|
42
|
+
await createProject(sql, ORG, OPEN_PROJECT);
|
|
43
|
+
await createProject(sql, ORG, LOCKED_PROJECT);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await sql.end();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("project visibility", () => {
|
|
51
|
+
test("projects default to 'org' visibility and are accessible to anyone", async () => {
|
|
52
|
+
const project = await getProject(sql, ORG, OPEN_PROJECT);
|
|
53
|
+
expect(project).not.toBeNull();
|
|
54
|
+
expect(project!.visibility).toBe("org");
|
|
55
|
+
expect(await userCanAccessProject(sql, ORG, OPEN_PROJECT, OUTSIDER)).toBe(true);
|
|
56
|
+
expect(await userCanAccessProject(sql, ORG, OPEN_PROJECT, null)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("'members' visibility blocks non-members but allows members", async () => {
|
|
60
|
+
await setProjectVisibility(sql, ORG, LOCKED_PROJECT, "members");
|
|
61
|
+
const project = await getProject(sql, ORG, LOCKED_PROJECT);
|
|
62
|
+
expect(project!.visibility).toBe("members");
|
|
63
|
+
|
|
64
|
+
// Non-member blocked
|
|
65
|
+
expect(await userCanAccessProject(sql, ORG, LOCKED_PROJECT, OUTSIDER)).toBe(false);
|
|
66
|
+
// Anonymous (null participant) stays allowed — dev/test back-compat
|
|
67
|
+
expect(await userCanAccessProject(sql, ORG, LOCKED_PROJECT, null)).toBe(true);
|
|
68
|
+
|
|
69
|
+
// Member allowed
|
|
70
|
+
await addProjectMember(sql, ORG, LOCKED_PROJECT, MEMBER, "editor");
|
|
71
|
+
expect(await userCanAccessProject(sql, ORG, LOCKED_PROJECT, MEMBER)).toBe(true);
|
|
72
|
+
|
|
73
|
+
// The open project is unaffected
|
|
74
|
+
expect(await userCanAccessProject(sql, ORG, OPEN_PROJECT, OUTSIDER)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("adding the same member twice upserts the role", async () => {
|
|
78
|
+
await addProjectMember(sql, ORG, LOCKED_PROJECT, MEMBER, "viewer");
|
|
79
|
+
const members = await listProjectMembers(sql, ORG, LOCKED_PROJECT);
|
|
80
|
+
const rows = members.filter((m) => m.participant_id === MEMBER);
|
|
81
|
+
expect(rows).toHaveLength(1);
|
|
82
|
+
expect(rows[0].role).toBe("viewer");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("removing a member revokes access", async () => {
|
|
86
|
+
await removeProjectMember(sql, ORG, LOCKED_PROJECT, MEMBER);
|
|
87
|
+
const members = await listProjectMembers(sql, ORG, LOCKED_PROJECT);
|
|
88
|
+
expect(members.find((m) => m.participant_id === MEMBER)).toBeUndefined();
|
|
89
|
+
expect(await userCanAccessProject(sql, ORG, LOCKED_PROJECT, MEMBER)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("switching back to 'org' restores access for everyone", async () => {
|
|
93
|
+
await setProjectVisibility(sql, ORG, LOCKED_PROJECT, "org");
|
|
94
|
+
expect(await userCanAccessProject(sql, ORG, LOCKED_PROJECT, OUTSIDER)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("unknown projects are treated as accessible (default-open)", async () => {
|
|
98
|
+
expect(await userCanAccessProject(sql, ORG, `pj-does-not-exist-${RUN}`, OUTSIDER)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|