@lightupai/polaris 0.0.4 → 0.0.6
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/.env.example +17 -0
- package/.github/workflows/ci.yml +38 -0
- package/.mcp.json +3 -3
- package/Makefile +20 -3
- package/README.md +124 -0
- package/bin/polaris +2 -2
- package/bun.lock +289 -0
- package/deploy.sh +18 -0
- package/docker/Caddyfile +7 -0
- package/docker/Dockerfile +13 -0
- package/docker/bridge-entrypoint.sh +17 -0
- package/docker-compose.prod.yml +85 -0
- package/docs/deploy-hetzner.md +99 -0
- package/hooks/capture-stop.sh +6 -0
- package/hooks/capture-stop.ts +122 -0
- package/hooks/capture.sh +1 -1
- package/hooks/statusline.sh +22 -11
- package/package.json +3 -1
- package/skills/polaris/SKILL.md +6 -2
- package/src/bridge-discover-org.ts +5 -0
- package/src/cli/cli.ts +401 -160
- package/src/client/client.ts +37 -24
- package/src/daemon/daemon.ts +250 -8
- package/src/service/db.ts +159 -28
- package/src/service/server.ts +47 -0
- package/src/slack/bridge.ts +399 -0
- package/src/slack/format.ts +115 -0
- package/src/types.ts +7 -1
- package/src/web/app.ts +40 -10
- package/src/web/layout.ts +16 -2
- package/src/web/views.ts +63 -77
- package/tests/bridge.test.ts +205 -0
- package/tests/client.test.ts +3 -13
- package/tests/daemon.test.ts +5 -14
- package/tests/e2e.test.ts +4 -13
- package/tests/format.test.ts +103 -0
- package/tests/helpers.ts +71 -0
- package/tests/service.test.ts +2 -13
- package/tests/types.test.ts +2 -2
- package/tests/web.test.ts +17 -31
package/src/web/views.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface ViewContext {
|
|
|
13
13
|
slackConnected: boolean;
|
|
14
14
|
cliInstalled: boolean;
|
|
15
15
|
hasConnectedSession: boolean;
|
|
16
|
+
totalPrompts: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function navOpts(ctx: ViewContext): NavOpts {
|
|
@@ -26,8 +27,7 @@ function copyBlock(text: string): string {
|
|
|
26
27
|
return `
|
|
27
28
|
<div class="mt-3 relative">
|
|
28
29
|
<code id="${id}" class="block bg-gray-50 border border-gray-200 rounded px-3 py-2 pr-10 text-xs font-mono select-all">${text}</code>
|
|
29
|
-
<button
|
|
30
|
-
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 transition rounded" title="Copy">
|
|
30
|
+
<button data-copy="${id}" class="polaris-copy absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 transition rounded" title="Copy">
|
|
31
31
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
|
32
32
|
</button>
|
|
33
33
|
</div>`;
|
|
@@ -64,35 +64,30 @@ function sectionWrap(state: StepState, content: string): string {
|
|
|
64
64
|
|
|
65
65
|
function renderFloorSection(ctx: ViewContext, compact = false, state: StepState = "done"): string {
|
|
66
66
|
if (compact && ctx.slackConnected) {
|
|
67
|
+
const promptStat = ctx.totalPrompts > 0
|
|
68
|
+
? `<span class="text-xs text-gray-400 ml-auto">${ctx.totalPrompts} prompt${ctx.totalPrompts !== 1 ? "s" : ""}</span>`
|
|
69
|
+
: '';
|
|
67
70
|
return `
|
|
68
71
|
<div>
|
|
69
|
-
|
|
72
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
73
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
|
|
74
|
+
${statusBadge("Connected", true)}
|
|
75
|
+
</div>
|
|
70
76
|
<div class="bg-white border border-gray-200 rounded-lg px-5 py-3 flex items-center gap-3">
|
|
71
77
|
<div class="w-8 h-8 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
|
|
72
78
|
${slackIcon.replace('class="w-4 h-4"', 'class="w-4 h-4 text-white"')}
|
|
73
79
|
</div>
|
|
74
80
|
<p class="text-sm font-medium text-gray-900">Slack</p>
|
|
75
|
-
${
|
|
81
|
+
${ctx.orgSlug ? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>` : ''}
|
|
82
|
+
${promptStat}
|
|
76
83
|
</div>
|
|
77
84
|
</div>`;
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
if (ctx.slackConnected) {
|
|
81
|
-
const
|
|
82
|
-
? `<
|
|
83
|
-
|
|
84
|
-
<p class="text-xs text-gray-400">Org identifier</p>
|
|
85
|
-
<p class="text-sm font-mono text-gray-700">${ctx.orgSlug}</p>
|
|
86
|
-
</div>
|
|
87
|
-
<form action="/settings/slug" method="POST" class="flex items-center gap-2">
|
|
88
|
-
<input type="hidden" name="token" value="${ctx.token}">
|
|
89
|
-
<input type="text" name="slug" value="${ctx.orgSlug}" class="px-2 py-1 border border-gray-200 rounded text-xs font-mono w-40 focus:ring-polaris-500 focus:border-polaris-500 outline-none hidden" id="slug-edit-${ctx.token.slice(-6)}">
|
|
90
|
-
<button type="button" onclick="const i=this.previousElementSibling;i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.focus()" class="text-xs text-gray-400 hover:text-gray-600">Edit</button>
|
|
91
|
-
</form>
|
|
92
|
-
</div>`
|
|
93
|
-
: `<div class="mt-3 pt-3 border-t border-gray-100">
|
|
94
|
-
<p class="text-xs text-gray-400">Org identifier: <span class="text-gray-500">not set (will be set from first floor connection)</span></p>
|
|
95
|
-
</div>`;
|
|
88
|
+
const slugLabel = ctx.orgSlug
|
|
89
|
+
? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>`
|
|
90
|
+
: '';
|
|
96
91
|
|
|
97
92
|
return `
|
|
98
93
|
<div>
|
|
@@ -100,17 +95,12 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
100
95
|
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
|
|
101
96
|
${statusBadge("Connected", true)}
|
|
102
97
|
</div>
|
|
103
|
-
<div class="bg-white border border-gray-200 rounded-lg
|
|
104
|
-
<div class="flex items-center
|
|
105
|
-
|
|
106
|
-
${slackIcon.replace('class="w-4 h-4"', 'class="w-5 h-5 text-white"')}
|
|
107
|
-
</div>
|
|
108
|
-
<div>
|
|
109
|
-
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
110
|
-
<p class="text-sm text-gray-500 mt-0.5">Workspace linked. Channels are auto-created for your projects.</p>
|
|
111
|
-
</div>
|
|
98
|
+
<div class="bg-white border border-gray-200 rounded-lg px-5 py-3 flex items-center gap-3">
|
|
99
|
+
<div class="w-8 h-8 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
|
|
100
|
+
${slackIcon.replace('class="w-4 h-4"', 'class="w-4 h-4 text-white"')}
|
|
112
101
|
</div>
|
|
113
|
-
|
|
102
|
+
<p class="text-sm font-medium text-gray-900">Slack</p>
|
|
103
|
+
${slugLabel}
|
|
114
104
|
</div>
|
|
115
105
|
</div>`;
|
|
116
106
|
}
|
|
@@ -153,7 +143,7 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
|
|
|
153
143
|
<summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Add another device</summary>
|
|
154
144
|
<div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
|
|
155
145
|
<p class="text-sm text-gray-500">Run on any new machine:</p>
|
|
156
|
-
${copyBlock("npx @lightupai/polaris
|
|
146
|
+
${copyBlock("npx @lightupai/polaris")}
|
|
157
147
|
</div>
|
|
158
148
|
</details>
|
|
159
149
|
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
|
|
@@ -172,7 +162,7 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
|
|
|
172
162
|
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
|
|
173
163
|
<p class="text-sm font-semibold text-gray-900">${ctx.cliInstalled ? "Add another device" : "Set up Polaris on your first device"}</p>
|
|
174
164
|
<p class="text-sm text-gray-500 mt-1">Run this in your terminal${ctx.cliInstalled ? " on any new machine" : ". Repeat on each machine you work from"}.</p>
|
|
175
|
-
${copyBlock("npx @lightupai/polaris
|
|
165
|
+
${copyBlock("npx @lightupai/polaris")}
|
|
176
166
|
</div>
|
|
177
167
|
</div>`);
|
|
178
168
|
}
|
|
@@ -206,10 +196,14 @@ function renderDeviceRow(device: DeviceFixture): string {
|
|
|
206
196
|
// --- Projects & Sessions section ---
|
|
207
197
|
|
|
208
198
|
function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixture[], projects: ProjectFixture[], state: StepState = "done"): string {
|
|
209
|
-
if (
|
|
199
|
+
if (projects.length > 0) {
|
|
200
|
+
const totalSessions = projects.reduce((n, p) => n + p.sessions.length, 0);
|
|
210
201
|
return `
|
|
211
202
|
<div>
|
|
212
|
-
|
|
203
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
204
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Projects & Sessions</h2>
|
|
205
|
+
${statusBadge(`${totalSessions} active`, true)}
|
|
206
|
+
</div>
|
|
213
207
|
<details class="mb-3">
|
|
214
208
|
<summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Join another session</summary>
|
|
215
209
|
<div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
|
|
@@ -217,13 +211,9 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
217
211
|
${copyBlock("/polaris join <project> <session>")}
|
|
218
212
|
</div>
|
|
219
213
|
</details>
|
|
220
|
-
<div class="space-y-
|
|
221
|
-
${
|
|
214
|
+
<div class="space-y-4">
|
|
215
|
+
${projects.map((p) => renderProjectCard(p, ctx.userName)).join("")}
|
|
222
216
|
</div>
|
|
223
|
-
${projects.length > 0 ? `
|
|
224
|
-
<div class="mt-4 space-y-3">
|
|
225
|
-
${projects.map((p) => renderProjectCard(p)).join("")}
|
|
226
|
-
</div>` : ""}
|
|
227
217
|
</div>`;
|
|
228
218
|
}
|
|
229
219
|
|
|
@@ -246,50 +236,46 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
246
236
|
</div>`);
|
|
247
237
|
}
|
|
248
238
|
|
|
249
|
-
function
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">Driver</span>'
|
|
253
|
-
: '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Advisor</span>';
|
|
254
|
-
|
|
255
|
-
const otherParticipants = session.participants
|
|
256
|
-
.filter((p) => p.id !== `user:${userName.toLowerCase().replace(/\s+/g, ".")}`)
|
|
257
|
-
.map((p) => `<span class="text-xs text-gray-500">${p.id}</span>`)
|
|
258
|
-
.join(", ");
|
|
259
|
-
|
|
260
|
-
return `
|
|
261
|
-
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
|
262
|
-
<div class="flex items-center justify-between">
|
|
263
|
-
<div class="flex items-center gap-2">
|
|
264
|
-
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
|
265
|
-
<p class="text-sm font-semibold text-gray-900">${session.project}/${session.name}</p>
|
|
266
|
-
${roleBadge}
|
|
267
|
-
</div>
|
|
268
|
-
<span class="text-xs text-gray-400">${session.eventCount} events</span>
|
|
269
|
-
</div>
|
|
270
|
-
<p class="text-sm text-gray-500 mt-1">${session.description}</p>
|
|
271
|
-
<div class="mt-2 flex items-center gap-1">
|
|
272
|
-
<span class="text-xs text-gray-400">with</span>
|
|
273
|
-
${otherParticipants}
|
|
274
|
-
</div>
|
|
275
|
-
</div>`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function renderProjectCard(project: ProjectFixture): string {
|
|
279
|
-
const activeSessions = project.sessions.length;
|
|
280
|
-
const drivers = project.sessions.map((s) => s.driver).filter((d, i, a) => a.indexOf(d) === i);
|
|
239
|
+
function renderProjectCard(project: ProjectFixture, userName: string): string {
|
|
240
|
+
const sessionCount = project.sessions.length;
|
|
241
|
+
const participantId = `user:${userName.toLowerCase().replace(/\s+/g, ".")}`;
|
|
281
242
|
|
|
282
243
|
return `
|
|
283
|
-
<div class="bg-white border border-gray-200 rounded-lg
|
|
284
|
-
<div class="flex items-center justify-between">
|
|
244
|
+
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
245
|
+
<div class="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
|
285
246
|
<div class="flex items-center gap-2">
|
|
286
247
|
<p class="text-sm font-semibold text-gray-900">${project.name}</p>
|
|
287
|
-
|
|
248
|
+
${project.slackChannel ? `<span class="text-xs text-gray-400">${project.slackChannel}</span>` : ""}
|
|
288
249
|
</div>
|
|
289
|
-
<span class="text-xs text-gray-400">${
|
|
250
|
+
<span class="text-xs text-gray-400">${sessionCount} session${sessionCount !== 1 ? "s" : ""}</span>
|
|
290
251
|
</div>
|
|
291
|
-
<div class="
|
|
292
|
-
${
|
|
252
|
+
<div class="divide-y divide-gray-50">
|
|
253
|
+
${project.sessions.map((s) => {
|
|
254
|
+
const isDriver = s.driver === participantId;
|
|
255
|
+
const roleBadge = isDriver
|
|
256
|
+
? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">Driver</span>'
|
|
257
|
+
: s.driver
|
|
258
|
+
? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Advisor</span>'
|
|
259
|
+
: '';
|
|
260
|
+
const driverLabel = s.driver && !isDriver
|
|
261
|
+
? `<span class="text-xs text-gray-400">${s.driver}</span>`
|
|
262
|
+
: '';
|
|
263
|
+
const promptLabel = s.eventCount > 0
|
|
264
|
+
? `<span class="text-xs text-gray-400">${s.eventCount} prompt${s.eventCount !== 1 ? "s" : ""}</span>`
|
|
265
|
+
: '';
|
|
266
|
+
return `
|
|
267
|
+
<div class="px-4 py-3 flex items-center justify-between">
|
|
268
|
+
<div class="flex items-center gap-2">
|
|
269
|
+
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
|
270
|
+
<p class="text-sm text-gray-700">${s.name}</p>
|
|
271
|
+
${roleBadge}
|
|
272
|
+
</div>
|
|
273
|
+
<div class="flex items-center gap-3">
|
|
274
|
+
${driverLabel}
|
|
275
|
+
${promptLabel}
|
|
276
|
+
</div>
|
|
277
|
+
</div>`;
|
|
278
|
+
}).join("")}
|
|
293
279
|
</div>
|
|
294
280
|
</div>`;
|
|
295
281
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
2
|
+
import { createDb, createOrg, createProject, createSession, getSessionEvents, pushEvent, type Sql } from "../src/service/db";
|
|
3
|
+
import { formatEventForSlack } from "../src/slack/format";
|
|
4
|
+
import type { PolarisEvent } from "../src/types";
|
|
5
|
+
import { resetTestData } from "./helpers";
|
|
6
|
+
|
|
7
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
8
|
+
|
|
9
|
+
let sql: Sql;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
sql = await createDb(DATABASE_URL);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await sql.end();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await resetTestData(sql);
|
|
21
|
+
|
|
22
|
+
await createOrg(sql, "test-org", "Test Org", "test.com");
|
|
23
|
+
await createProject(sql, "test-org", "myproject");
|
|
24
|
+
await createSession(sql, "test-org", "myproject", "fxm", "user:manu");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("bridge: Slack → DB injection", () => {
|
|
28
|
+
test("inject message writes to events table", async () => {
|
|
29
|
+
// Simulate what handleSlackMessage does after parsing @fxm content
|
|
30
|
+
const { getSession } = await import("../src/service/db");
|
|
31
|
+
|
|
32
|
+
const session = await getSession(sql, "test-org", "myproject", "fxm");
|
|
33
|
+
expect(session).not.toBeNull();
|
|
34
|
+
|
|
35
|
+
const injectEvent: PolarisEvent = {
|
|
36
|
+
id: crypto.randomUUID(),
|
|
37
|
+
project: "myproject",
|
|
38
|
+
session: "fxm",
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
source: "inject",
|
|
41
|
+
sender: "slack:manu.bansal" as PolarisEvent["sender"],
|
|
42
|
+
payload: {
|
|
43
|
+
type: "inject" as const,
|
|
44
|
+
content: "add token refresh",
|
|
45
|
+
sender: "slack:manu.bansal" as PolarisEvent["sender"],
|
|
46
|
+
target: "fxm",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await pushEvent(sql, "test-org", injectEvent);
|
|
51
|
+
|
|
52
|
+
const events = await getSessionEvents(sql, "test-org", "myproject", "fxm");
|
|
53
|
+
expect(events).toHaveLength(1);
|
|
54
|
+
expect(events[0].source).toBe("inject");
|
|
55
|
+
expect(events[0].sender).toBe("slack:manu.bansal");
|
|
56
|
+
expect((events[0].payload as { content: string }).content).toBe("add token refresh");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("inject with slack: sender prefix validates", async () => {
|
|
60
|
+
const event: PolarisEvent = {
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
project: "myproject",
|
|
63
|
+
session: "fxm",
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
source: "inject",
|
|
66
|
+
sender: "slack:krishna" as PolarisEvent["sender"],
|
|
67
|
+
payload: {
|
|
68
|
+
type: "inject" as const,
|
|
69
|
+
content: "use RS256",
|
|
70
|
+
sender: "slack:krishna" as PolarisEvent["sender"],
|
|
71
|
+
target: "fxm",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await pushEvent(sql, "test-org", event);
|
|
76
|
+
|
|
77
|
+
const events = await getSessionEvents(sql, "test-org", "myproject", "fxm");
|
|
78
|
+
expect(events[0].sender).toBe("slack:krishna");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("inject to nonexistent session can auto-create", async () => {
|
|
82
|
+
// Session doesn't exist yet
|
|
83
|
+
let session = await getSessionEvents(sql, "test-org", "myproject", "new-session");
|
|
84
|
+
expect(session).toHaveLength(0);
|
|
85
|
+
|
|
86
|
+
// Create session on the fly (like the bridge would)
|
|
87
|
+
await createSession(sql, "test-org", "myproject", "new-session", null);
|
|
88
|
+
|
|
89
|
+
const event: PolarisEvent = {
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
project: "myproject",
|
|
92
|
+
session: "new-session",
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
source: "inject",
|
|
95
|
+
sender: "slack:advisor" as PolarisEvent["sender"],
|
|
96
|
+
payload: {
|
|
97
|
+
type: "inject" as const,
|
|
98
|
+
content: "advice for new session",
|
|
99
|
+
sender: "slack:advisor" as PolarisEvent["sender"],
|
|
100
|
+
target: "new-session",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await pushEvent(sql, "test-org", event);
|
|
105
|
+
|
|
106
|
+
const events = await getSessionEvents(sql, "test-org", "myproject", "new-session");
|
|
107
|
+
expect(events).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("bridge: DB → Slack formatting", () => {
|
|
112
|
+
test("formats UserPromptSubmit for Slack", () => {
|
|
113
|
+
const event: PolarisEvent = {
|
|
114
|
+
id: crypto.randomUUID(),
|
|
115
|
+
project: "myproject",
|
|
116
|
+
session: "fxm",
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
source: "hook",
|
|
119
|
+
sender: "user:manu",
|
|
120
|
+
payload: {
|
|
121
|
+
hook_event_name: "UserPromptSubmit",
|
|
122
|
+
session_id: "s1",
|
|
123
|
+
prompt: "build auth middleware",
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const result = formatEventForSlack(event);
|
|
128
|
+
expect(result).not.toBeNull();
|
|
129
|
+
expect(result!.username).toContain("Manu");
|
|
130
|
+
expect(result!.username).toContain("fxm");
|
|
131
|
+
expect(result!.text).toContain("build auth middleware");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("skips _system events", () => {
|
|
135
|
+
const event: PolarisEvent = {
|
|
136
|
+
id: crypto.randomUUID(),
|
|
137
|
+
project: "_system",
|
|
138
|
+
session: "_system",
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
source: "hook",
|
|
141
|
+
sender: "user:manu",
|
|
142
|
+
payload: {
|
|
143
|
+
hook_event_name: "Stop",
|
|
144
|
+
session_id: "_system",
|
|
145
|
+
stop_response: "Device connected",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// The bridge skips _system in postEventToSlack, not in formatEventForSlack
|
|
150
|
+
// But formatEventForSlack still formats it — the bridge filters upstream
|
|
151
|
+
const result = formatEventForSlack(event);
|
|
152
|
+
expect(result).not.toBeNull(); // formatter doesn't filter _system
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("skips tool calls", () => {
|
|
156
|
+
const event: PolarisEvent = {
|
|
157
|
+
id: crypto.randomUUID(),
|
|
158
|
+
project: "myproject",
|
|
159
|
+
session: "fxm",
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
source: "hook",
|
|
162
|
+
sender: "user:manu",
|
|
163
|
+
payload: {
|
|
164
|
+
hook_event_name: "PreToolUse",
|
|
165
|
+
session_id: "s1",
|
|
166
|
+
tool_name: "Bash",
|
|
167
|
+
tool_input: { command: "ls" },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
expect(formatEventForSlack(event)).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("bridge: message parsing", () => {
|
|
176
|
+
test("parses @session content format", () => {
|
|
177
|
+
const text = "@fxm add token refresh";
|
|
178
|
+
const match = text.match(/^@(\S+)\s+(.+)$/s);
|
|
179
|
+
expect(match).not.toBeNull();
|
|
180
|
+
expect(match![1]).toBe("fxm");
|
|
181
|
+
expect(match![2]).toBe("add token refresh");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("parses session: content format", () => {
|
|
185
|
+
const text = "fxm: add token refresh";
|
|
186
|
+
const match = text.match(/^(\S+):\s+(.+)$/s);
|
|
187
|
+
expect(match).not.toBeNull();
|
|
188
|
+
expect(match![1]).toBe("fxm");
|
|
189
|
+
expect(match![2]).toBe("add token refresh");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("no match for plain message", () => {
|
|
193
|
+
const text = "just a regular message";
|
|
194
|
+
const match = text.match(/^@(\S+)\s+(.+)$/s) || text.match(/^(\S+):\s+(.+)$/s);
|
|
195
|
+
expect(match).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("handles multiline content", () => {
|
|
199
|
+
const text = "@fxm here is some advice\nwith multiple lines\nof content";
|
|
200
|
+
const match = text.match(/^@(\S+)\s+(.+)$/s);
|
|
201
|
+
expect(match).not.toBeNull();
|
|
202
|
+
expect(match![1]).toBe("fxm");
|
|
203
|
+
expect(match![2]).toContain("multiple lines");
|
|
204
|
+
});
|
|
205
|
+
});
|
package/tests/client.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
|
|
|
2
2
|
import { startServer } from "../src/service/server";
|
|
3
3
|
import { startDaemon } from "../src/daemon/daemon";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
|
+
import { resetTestData } from "./helpers";
|
|
5
6
|
|
|
6
7
|
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
8
|
|
|
@@ -18,6 +19,7 @@ beforeAll(async () => {
|
|
|
18
19
|
serviceUrl = `http://localhost:${s.server.port}`;
|
|
19
20
|
|
|
20
21
|
process.env.POLARIS_SERVICE_URL = serviceUrl;
|
|
22
|
+
process.env.POLARIS_AUTH_TOKEN = "";
|
|
21
23
|
const d = startDaemon(0);
|
|
22
24
|
stopDaemon = d.stop;
|
|
23
25
|
daemonUrl = `http://127.0.0.1:${d.server.port}`;
|
|
@@ -29,19 +31,7 @@ afterAll(async () => {
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
beforeEach(async () => {
|
|
32
|
-
await sql
|
|
33
|
-
await sql`DROP TABLE IF EXISTS sessions`;
|
|
34
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
36
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
38
|
-
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
39
|
-
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
40
|
-
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
|
41
|
-
await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
|
|
42
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
|
|
43
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
|
|
44
|
-
await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
|
|
34
|
+
await resetTestData(sql);
|
|
45
35
|
});
|
|
46
36
|
|
|
47
37
|
async function post(base: string, path: string, body: unknown) {
|
package/tests/daemon.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
|
|
|
2
2
|
import { startDaemon } from "../src/daemon/daemon";
|
|
3
3
|
import { startServer } from "../src/service/server";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
|
+
import { resetTestData } from "./helpers";
|
|
5
6
|
|
|
6
7
|
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
8
|
|
|
@@ -20,6 +21,7 @@ beforeAll(async () => {
|
|
|
20
21
|
|
|
21
22
|
// Start daemon pointed at the cloud service
|
|
22
23
|
process.env.POLARIS_SERVICE_URL = serviceUrl;
|
|
24
|
+
process.env.POLARIS_AUTH_TOKEN = "";
|
|
23
25
|
const d = startDaemon(0);
|
|
24
26
|
stopDaemon = d.stop;
|
|
25
27
|
daemonUrl = `http://127.0.0.1:${d.server.port}`;
|
|
@@ -31,19 +33,7 @@ afterAll(async () => {
|
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
beforeEach(async () => {
|
|
34
|
-
await sql
|
|
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`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
40
|
-
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
41
|
-
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
42
|
-
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
|
43
|
-
await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
|
|
44
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
|
|
45
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
|
|
46
|
-
await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
|
|
36
|
+
await resetTestData(sql);
|
|
47
37
|
});
|
|
48
38
|
|
|
49
39
|
async function post(base: string, path: string, body: unknown) {
|
|
@@ -163,7 +153,8 @@ describe("daemon /events (hook relay)", () => {
|
|
|
163
153
|
expect(body[0].payload.prompt).toBe("build auth middleware");
|
|
164
154
|
});
|
|
165
155
|
|
|
166
|
-
test("discards events
|
|
156
|
+
test("discards events when no sessions are connected", async () => {
|
|
157
|
+
await post(daemonUrl, "/disconnect-all", {});
|
|
167
158
|
const res = await post(daemonUrl, "/events", {
|
|
168
159
|
session_id: "cc-unknown",
|
|
169
160
|
hook_event_name: "UserPromptSubmit",
|
package/tests/e2e.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
|
|
|
2
2
|
import { startServer } from "../src/service/server";
|
|
3
3
|
import { startDaemon } from "../src/daemon/daemon";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
|
+
import { resetTestData } from "./helpers";
|
|
5
6
|
|
|
6
7
|
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
8
|
|
|
@@ -18,6 +19,7 @@ beforeAll(async () => {
|
|
|
18
19
|
serviceUrl = `http://localhost:${s.server.port}`;
|
|
19
20
|
|
|
20
21
|
process.env.POLARIS_SERVICE_URL = serviceUrl;
|
|
22
|
+
process.env.POLARIS_AUTH_TOKEN = "";
|
|
21
23
|
const d = startDaemon(0);
|
|
22
24
|
stopDaemon = d.stop;
|
|
23
25
|
daemonUrl = `http://127.0.0.1:${d.server.port}`;
|
|
@@ -29,19 +31,7 @@ afterAll(async () => {
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
beforeEach(async () => {
|
|
32
|
-
await sql
|
|
33
|
-
await sql`DROP TABLE IF EXISTS sessions`;
|
|
34
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
36
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
38
|
-
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
39
|
-
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
40
|
-
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
|
41
|
-
await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
|
|
42
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
|
|
43
|
-
await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
|
|
44
|
-
await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
|
|
34
|
+
await resetTestData(sql);
|
|
45
35
|
});
|
|
46
36
|
|
|
47
37
|
async function post(base: string, path: string, body: unknown) {
|
|
@@ -355,6 +345,7 @@ describe("e2e: capture.sh through daemon", () => {
|
|
|
355
345
|
});
|
|
356
346
|
|
|
357
347
|
test("hook events for unconnected sessions are silently discarded", async () => {
|
|
348
|
+
await post(daemonUrl, "/disconnect-all", {});
|
|
358
349
|
const res = await post(daemonUrl, "/events", {
|
|
359
350
|
session_id: "cc-nobody",
|
|
360
351
|
hook_event_name: "UserPromptSubmit",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatEventForSlack } from "../src/slack/format";
|
|
3
|
+
import type { PolarisEvent } from "../src/types";
|
|
4
|
+
|
|
5
|
+
function makeEvent(overrides: Partial<PolarisEvent> = {}): PolarisEvent {
|
|
6
|
+
return {
|
|
7
|
+
id: crypto.randomUUID(),
|
|
8
|
+
project: "pj",
|
|
9
|
+
session: "fxm",
|
|
10
|
+
timestamp: new Date().toISOString(),
|
|
11
|
+
source: "hook",
|
|
12
|
+
sender: "user:manu",
|
|
13
|
+
payload: {
|
|
14
|
+
hook_event_name: "UserPromptSubmit",
|
|
15
|
+
session_id: "s1",
|
|
16
|
+
prompt: "build auth middleware",
|
|
17
|
+
},
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("formatEventForSlack", () => {
|
|
23
|
+
test("user prompt: persona with session, plain message", () => {
|
|
24
|
+
const result = formatEventForSlack(makeEvent());
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
expect(result!.username).toBe("Manu (fxm)");
|
|
27
|
+
expect(result!.icon_emoji).toBe(":bust_in_silhouette:");
|
|
28
|
+
expect(result!.text).toContain("build auth middleware");
|
|
29
|
+
expect(result!.blocks).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("agent response: robot persona with session", () => {
|
|
33
|
+
const result = formatEventForSlack(makeEvent({
|
|
34
|
+
payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "Created auth.ts" },
|
|
35
|
+
}));
|
|
36
|
+
expect(result).not.toBeNull();
|
|
37
|
+
expect(result!.username).toBe("Agent (fxm)");
|
|
38
|
+
expect(result!.icon_emoji).toBe(":robot_face:");
|
|
39
|
+
expect(result!.text).toContain("Created auth.ts");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("skips PreToolUse", () => {
|
|
43
|
+
expect(formatEventForSlack(makeEvent({
|
|
44
|
+
payload: { hook_event_name: "PreToolUse", session_id: "s1", tool_name: "Bash", tool_input: { command: "ls" } },
|
|
45
|
+
}))).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("skips PostToolUse", () => {
|
|
49
|
+
expect(formatEventForSlack(makeEvent({
|
|
50
|
+
payload: { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "Read", tool_input: { file_path: "/tmp" }, tool_result: {} },
|
|
51
|
+
}))).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("advisor: persona + target in message", () => {
|
|
55
|
+
const result = formatEventForSlack(makeEvent({
|
|
56
|
+
source: "inject",
|
|
57
|
+
sender: "user:krishna",
|
|
58
|
+
payload: { type: "inject" as const, content: "Use RS256", sender: "user:krishna", target: "fxm" },
|
|
59
|
+
}));
|
|
60
|
+
expect(result).not.toBeNull();
|
|
61
|
+
expect(result!.username).toBe("Krishna");
|
|
62
|
+
expect(result!.text).toContain("Use RS256");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reply: persona", () => {
|
|
66
|
+
const result = formatEventForSlack(makeEvent({
|
|
67
|
+
source: "reply",
|
|
68
|
+
sender: "user:manu",
|
|
69
|
+
payload: { type: "reply" as const, content: "Done", sender: "user:manu" },
|
|
70
|
+
}));
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result!.username).toBe("Manu");
|
|
73
|
+
expect(result!.text).toContain("Done");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("long messages include full text for bridge threading", () => {
|
|
77
|
+
const result = formatEventForSlack(makeEvent({
|
|
78
|
+
payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "x".repeat(5000) },
|
|
79
|
+
}));
|
|
80
|
+
expect(result).not.toBeNull();
|
|
81
|
+
expect(result!.text!.length).toBe(5000);
|
|
82
|
+
expect(result!.blocks).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("converts markdown bold to mrkdwn", () => {
|
|
86
|
+
const result = formatEventForSlack(makeEvent({
|
|
87
|
+
payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "This is **bold** text" },
|
|
88
|
+
}));
|
|
89
|
+
expect(result).not.toBeNull();
|
|
90
|
+
expect(result!.text).toContain("*bold*");
|
|
91
|
+
expect(result!.text).not.toContain("**bold**");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("agent: sender persona for agent participants", () => {
|
|
95
|
+
const result = formatEventForSlack(makeEvent({
|
|
96
|
+
sender: "agent:test-writer",
|
|
97
|
+
payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "write tests" },
|
|
98
|
+
}));
|
|
99
|
+
expect(result).not.toBeNull();
|
|
100
|
+
expect(result!.username).toBe("Agent: Test Writer (fxm)");
|
|
101
|
+
expect(result!.icon_emoji).toBe(":robot_face:");
|
|
102
|
+
});
|
|
103
|
+
});
|