@lightupai/polaris 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +38 -0
- package/.mcp.json +12 -0
- package/LICENSE +201 -0
- package/Makefile +38 -0
- package/PLAN.md +438 -0
- package/docker-compose.yml +14 -0
- package/docs/README.md +377 -0
- package/hooks/capture.sh +19 -0
- package/hooks/statusline.sh +30 -0
- package/package.json +22 -0
- package/scripts/setup-google-oauth.sh +111 -0
- package/scripts/setup-slack-app.sh +115 -0
- package/skills/polaris/SKILL.md +29 -0
- package/src/cli/cli.ts +294 -0
- package/src/client/client.ts +245 -0
- package/src/daemon/daemon.ts +275 -0
- package/src/service/auth.ts +45 -0
- package/src/service/db.ts +275 -0
- package/src/service/server.ts +406 -0
- package/src/slack/system.ts +107 -0
- package/src/types.ts +108 -0
- package/src/web/app.ts +397 -0
- package/src/web/fixtures.ts +121 -0
- package/src/web/layout.ts +68 -0
- package/src/web/pages.ts +156 -0
- package/src/web/serve.ts +13 -0
- package/src/web/views.ts +356 -0
- package/tests/auth.test.ts +37 -0
- package/tests/client.test.ts +187 -0
- package/tests/daemon.test.ts +220 -0
- package/tests/db.test.ts +282 -0
- package/tests/e2e.test.ts +415 -0
- package/tests/service.test.ts +365 -0
- package/tests/types.test.ts +240 -0
- package/tests/web.test.ts +420 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
2
|
+
import { startServer } from "../src/service/server";
|
|
3
|
+
import { startDaemon } from "../src/daemon/daemon";
|
|
4
|
+
import type { Sql } from "../src/service/db";
|
|
5
|
+
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
|
|
7
|
+
|
|
8
|
+
let serviceUrl: string;
|
|
9
|
+
let daemonUrl: string;
|
|
10
|
+
let sql: Sql;
|
|
11
|
+
let stopService: () => Promise<void>;
|
|
12
|
+
let stopDaemon: () => void;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
const s = await startServer({ port: 0, databaseUrl: DATABASE_URL });
|
|
16
|
+
sql = s.sql;
|
|
17
|
+
stopService = s.stop;
|
|
18
|
+
serviceUrl = `http://localhost:${s.server.port}`;
|
|
19
|
+
|
|
20
|
+
process.env.POLARIS_SERVICE_URL = serviceUrl;
|
|
21
|
+
const d = startDaemon(0);
|
|
22
|
+
stopDaemon = d.stop;
|
|
23
|
+
daemonUrl = `http://127.0.0.1:${d.server.port}`;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
stopDaemon();
|
|
28
|
+
await stopService();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await sql`DROP TABLE IF EXISTS events`;
|
|
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, 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`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function post(base: string, path: string, body: unknown) {
|
|
48
|
+
return fetch(`${base}${path}`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function get(base: string, path: string) {
|
|
56
|
+
return fetch(`${base}${path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("end-to-end: daemon + cloud service", () => {
|
|
60
|
+
test("connect → hook event → appears in cloud", async () => {
|
|
61
|
+
await post(daemonUrl, "/connect", {
|
|
62
|
+
ccSessionId: "e2e-1",
|
|
63
|
+
project: "pj",
|
|
64
|
+
session: "fxm",
|
|
65
|
+
user: "user:manu",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await post(daemonUrl, "/events", {
|
|
69
|
+
session_id: "e2e-1",
|
|
70
|
+
hook_event_name: "UserPromptSubmit",
|
|
71
|
+
prompt: "build auth",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const res = await get(serviceUrl, "/projects/pj/sessions/fxm/messages");
|
|
75
|
+
const body = await res.json();
|
|
76
|
+
expect(body).toHaveLength(1);
|
|
77
|
+
expect(body[0].payload.prompt).toBe("build auth");
|
|
78
|
+
expect(body[0].sender).toBe("user:manu");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("inject event reaches session WS via daemon", async () => {
|
|
82
|
+
await post(daemonUrl, "/connect", {
|
|
83
|
+
ccSessionId: "e2e-2",
|
|
84
|
+
project: "pj",
|
|
85
|
+
session: "fxm",
|
|
86
|
+
user: "user:manu",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const wsUrl = serviceUrl.replace("http", "ws");
|
|
90
|
+
const ws = new WebSocket(`${wsUrl}/projects/pj/sessions/fxm/ws`);
|
|
91
|
+
const received: unknown[] = [];
|
|
92
|
+
await new Promise<void>((resolve) => { ws.onopen = () => resolve(); });
|
|
93
|
+
ws.onmessage = (e) => received.push(JSON.parse(e.data as string));
|
|
94
|
+
|
|
95
|
+
await post(serviceUrl, "/projects/pj/sessions/fxm/inject", {
|
|
96
|
+
content: "Use RS256",
|
|
97
|
+
sender: "user:krishna",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
101
|
+
expect(received).toHaveLength(1);
|
|
102
|
+
expect((received[0] as { source: string }).source).toBe("inject");
|
|
103
|
+
ws.close();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("multiple sessions on same machine route independently", async () => {
|
|
107
|
+
await post(daemonUrl, "/connect", {
|
|
108
|
+
ccSessionId: "multi-1",
|
|
109
|
+
project: "pj",
|
|
110
|
+
session: "fxm",
|
|
111
|
+
user: "user:manu",
|
|
112
|
+
});
|
|
113
|
+
await post(daemonUrl, "/connect", {
|
|
114
|
+
ccSessionId: "multi-2",
|
|
115
|
+
project: "pj",
|
|
116
|
+
session: "fxk",
|
|
117
|
+
user: "user:krishna",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await post(daemonUrl, "/events", {
|
|
121
|
+
session_id: "multi-1",
|
|
122
|
+
hook_event_name: "UserPromptSubmit",
|
|
123
|
+
prompt: "from fxm",
|
|
124
|
+
});
|
|
125
|
+
await post(daemonUrl, "/events", {
|
|
126
|
+
session_id: "multi-2",
|
|
127
|
+
hook_event_name: "UserPromptSubmit",
|
|
128
|
+
prompt: "from fxk",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const fxmRes = await get(serviceUrl, "/projects/pj/sessions/fxm/messages");
|
|
132
|
+
const fxmBody = await fxmRes.json();
|
|
133
|
+
expect(fxmBody).toHaveLength(1);
|
|
134
|
+
expect(fxmBody[0].payload.prompt).toBe("from fxm");
|
|
135
|
+
expect(fxmBody[0].sender).toBe("user:manu");
|
|
136
|
+
|
|
137
|
+
const fxkRes = await get(serviceUrl, "/projects/pj/sessions/fxk/messages");
|
|
138
|
+
const fxkBody = await fxkRes.json();
|
|
139
|
+
expect(fxkBody).toHaveLength(1);
|
|
140
|
+
expect(fxkBody[0].payload.prompt).toBe("from fxk");
|
|
141
|
+
expect(fxkBody[0].sender).toBe("user:krishna");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("capture.sh with daemon", () => {
|
|
146
|
+
test("script relays stdin to daemon", async () => {
|
|
147
|
+
await post(daemonUrl, "/connect", {
|
|
148
|
+
ccSessionId: "capture-1",
|
|
149
|
+
project: "pj",
|
|
150
|
+
session: "fxm",
|
|
151
|
+
user: "user:manu",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const hookPayload = JSON.stringify({
|
|
155
|
+
session_id: "capture-1",
|
|
156
|
+
hook_event_name: "UserPromptSubmit",
|
|
157
|
+
prompt: "test from capture.sh",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const proc = Bun.spawn(["sh", "hooks/capture.sh"], {
|
|
161
|
+
stdin: "pipe",
|
|
162
|
+
env: { ...process.env, POLARIS_PORT: String(new URL(daemonUrl).port) },
|
|
163
|
+
});
|
|
164
|
+
proc.stdin.write(hookPayload);
|
|
165
|
+
proc.stdin.end();
|
|
166
|
+
const exitCode = await proc.exited;
|
|
167
|
+
expect(exitCode).toBe(0);
|
|
168
|
+
|
|
169
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
170
|
+
|
|
171
|
+
const res = await get(serviceUrl, "/projects/pj/sessions/fxm/messages");
|
|
172
|
+
const body = await res.json();
|
|
173
|
+
expect(body).toHaveLength(1);
|
|
174
|
+
expect(body[0].payload.prompt).toBe("test from capture.sh");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("script exits 0 when daemon is down", async () => {
|
|
178
|
+
const proc = Bun.spawn(["sh", "hooks/capture.sh"], {
|
|
179
|
+
stdin: "pipe",
|
|
180
|
+
env: { ...process.env, POLARIS_PORT: "59999" },
|
|
181
|
+
});
|
|
182
|
+
proc.stdin.write('{"session_id":"x","test":true}');
|
|
183
|
+
proc.stdin.end();
|
|
184
|
+
const exitCode = await proc.exited;
|
|
185
|
+
expect(exitCode).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
2
|
+
import { startDaemon } from "../src/daemon/daemon";
|
|
3
|
+
import { startServer } from "../src/service/server";
|
|
4
|
+
import type { Sql } from "../src/service/db";
|
|
5
|
+
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
|
|
7
|
+
|
|
8
|
+
let daemonUrl: string;
|
|
9
|
+
let serviceUrl: string;
|
|
10
|
+
let sql: Sql;
|
|
11
|
+
let stopDaemon: () => void;
|
|
12
|
+
let stopService: () => Promise<void>;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
// Start cloud service
|
|
16
|
+
const s = await startServer({ port: 0, databaseUrl: DATABASE_URL });
|
|
17
|
+
sql = s.sql;
|
|
18
|
+
stopService = s.stop;
|
|
19
|
+
serviceUrl = `http://localhost:${s.server.port}`;
|
|
20
|
+
|
|
21
|
+
// Start daemon pointed at the cloud service
|
|
22
|
+
process.env.POLARIS_SERVICE_URL = serviceUrl;
|
|
23
|
+
const d = startDaemon(0);
|
|
24
|
+
stopDaemon = d.stop;
|
|
25
|
+
daemonUrl = `http://127.0.0.1:${d.server.port}`;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
stopDaemon();
|
|
30
|
+
await stopService();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
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`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, 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`;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function post(base: string, path: string, body: unknown) {
|
|
50
|
+
return fetch(`${base}${path}`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function get(base: string, path: string) {
|
|
58
|
+
return fetch(`${base}${path}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("daemon /register", () => {
|
|
62
|
+
test("registers a CC session", async () => {
|
|
63
|
+
const res = await post(daemonUrl, "/register", { ccSessionId: "cc-1" });
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
expect(body.status).toBe("registered");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects missing ccSessionId", async () => {
|
|
70
|
+
const res = await post(daemonUrl, "/register", {});
|
|
71
|
+
expect(res.status).toBe(400);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("daemon /connect", () => {
|
|
76
|
+
test("connects a CC session to a polaris project/session", async () => {
|
|
77
|
+
const res = await post(daemonUrl, "/connect", {
|
|
78
|
+
ccSessionId: "cc-2",
|
|
79
|
+
project: "pj",
|
|
80
|
+
session: "fxm",
|
|
81
|
+
user: "user:manu",
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
expect(body.status).toBe("connected");
|
|
86
|
+
expect(body.project).toBe("pj");
|
|
87
|
+
|
|
88
|
+
// Verify project and session were created on cloud
|
|
89
|
+
const projRes = await get(serviceUrl, "/projects/pj");
|
|
90
|
+
expect(projRes.status).toBe(200);
|
|
91
|
+
|
|
92
|
+
const sessRes = await get(serviceUrl, "/projects/pj/sessions/fxm");
|
|
93
|
+
expect(sessRes.status).toBe(200);
|
|
94
|
+
const sess = await sessRes.json();
|
|
95
|
+
expect(sess.driver).toBe("user:manu");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects missing fields", async () => {
|
|
99
|
+
const res = await post(daemonUrl, "/connect", { ccSessionId: "cc-3" });
|
|
100
|
+
expect(res.status).toBe(400);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("connecting twice switches sessions", async () => {
|
|
104
|
+
await post(daemonUrl, "/connect", {
|
|
105
|
+
ccSessionId: "cc-4",
|
|
106
|
+
project: "pj",
|
|
107
|
+
session: "fxm",
|
|
108
|
+
user: "user:manu",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const res = await post(daemonUrl, "/connect", {
|
|
112
|
+
ccSessionId: "cc-4",
|
|
113
|
+
project: "pj",
|
|
114
|
+
session: "fxk",
|
|
115
|
+
user: "user:manu",
|
|
116
|
+
});
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
|
|
119
|
+
const status = await get(daemonUrl, "/status/cc-4");
|
|
120
|
+
const body = await status.json();
|
|
121
|
+
expect(body.session).toBe("fxk");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("daemon /disconnect", () => {
|
|
126
|
+
test("disconnects a CC session", async () => {
|
|
127
|
+
await post(daemonUrl, "/connect", {
|
|
128
|
+
ccSessionId: "cc-5",
|
|
129
|
+
project: "pj",
|
|
130
|
+
session: "fxm",
|
|
131
|
+
user: "user:manu",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const res = await post(daemonUrl, "/disconnect", { ccSessionId: "cc-5" });
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
|
|
137
|
+
const status = await get(daemonUrl, "/status/cc-5");
|
|
138
|
+
const body = await status.json();
|
|
139
|
+
expect(body.connected).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("daemon /events (hook relay)", () => {
|
|
144
|
+
test("relays hook events to cloud service", async () => {
|
|
145
|
+
await post(daemonUrl, "/connect", {
|
|
146
|
+
ccSessionId: "cc-6",
|
|
147
|
+
project: "pj",
|
|
148
|
+
session: "fxm",
|
|
149
|
+
user: "user:manu",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const res = await post(daemonUrl, "/events", {
|
|
153
|
+
session_id: "cc-6",
|
|
154
|
+
hook_event_name: "UserPromptSubmit",
|
|
155
|
+
prompt: "build auth middleware",
|
|
156
|
+
});
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
|
|
159
|
+
// Verify event reached cloud
|
|
160
|
+
const messages = await get(serviceUrl, "/projects/pj/sessions/fxm/messages");
|
|
161
|
+
const body = await messages.json();
|
|
162
|
+
expect(body).toHaveLength(1);
|
|
163
|
+
expect(body[0].payload.prompt).toBe("build auth middleware");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("discards events for unconnected sessions", async () => {
|
|
167
|
+
const res = await post(daemonUrl, "/events", {
|
|
168
|
+
session_id: "cc-unknown",
|
|
169
|
+
hook_event_name: "UserPromptSubmit",
|
|
170
|
+
prompt: "hello",
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(200);
|
|
173
|
+
const body = await res.json();
|
|
174
|
+
expect(body.status).toBe("not_connected");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("daemon /status", () => {
|
|
179
|
+
test("returns session-specific status", async () => {
|
|
180
|
+
await post(daemonUrl, "/connect", {
|
|
181
|
+
ccSessionId: "cc-7",
|
|
182
|
+
project: "pj",
|
|
183
|
+
session: "fxm",
|
|
184
|
+
user: "user:manu",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const res = await get(daemonUrl, "/status/cc-7");
|
|
188
|
+
const body = await res.json();
|
|
189
|
+
expect(body.connected).toBe(true);
|
|
190
|
+
expect(body.project).toBe("pj");
|
|
191
|
+
expect(body.session).toBe("fxm");
|
|
192
|
+
expect(body.user).toBe("user:manu");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("returns not connected for unknown session", async () => {
|
|
196
|
+
const res = await get(daemonUrl, "/status/cc-nonexistent");
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
expect(body.connected).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("returns daemon health with all active sessions", async () => {
|
|
202
|
+
await post(daemonUrl, "/connect", {
|
|
203
|
+
ccSessionId: "cc-8a",
|
|
204
|
+
project: "pj",
|
|
205
|
+
session: "fxm",
|
|
206
|
+
user: "user:manu",
|
|
207
|
+
});
|
|
208
|
+
await post(daemonUrl, "/connect", {
|
|
209
|
+
ccSessionId: "cc-8b",
|
|
210
|
+
project: "pj",
|
|
211
|
+
session: "fxk",
|
|
212
|
+
user: "user:krishna",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const res = await get(daemonUrl, "/status");
|
|
216
|
+
const body = await res.json();
|
|
217
|
+
expect(body.ok).toBe(true);
|
|
218
|
+
expect(body.sessions.length).toBeGreaterThanOrEqual(2);
|
|
219
|
+
});
|
|
220
|
+
});
|
package/tests/db.test.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createDb,
|
|
4
|
+
createOrg,
|
|
5
|
+
getOrg,
|
|
6
|
+
getOrgByDomain,
|
|
7
|
+
setOrgSlack,
|
|
8
|
+
createUser,
|
|
9
|
+
getUser,
|
|
10
|
+
getUserByEmail,
|
|
11
|
+
upsertUser,
|
|
12
|
+
createProject,
|
|
13
|
+
getProject,
|
|
14
|
+
createSession,
|
|
15
|
+
getSession,
|
|
16
|
+
setDriver,
|
|
17
|
+
clearDriver,
|
|
18
|
+
pushEvent,
|
|
19
|
+
getProjectEvents,
|
|
20
|
+
getSessionEvents,
|
|
21
|
+
getEventsSince,
|
|
22
|
+
type Sql,
|
|
23
|
+
} from "../src/service/db";
|
|
24
|
+
import type { PolarisEvent } from "../src/types";
|
|
25
|
+
|
|
26
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
|
|
27
|
+
|
|
28
|
+
let sql: Sql;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
sql = await createDb(DATABASE_URL);
|
|
32
|
+
await sql`DROP TABLE IF EXISTS events`;
|
|
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.end();
|
|
38
|
+
sql = await createDb(DATABASE_URL);
|
|
39
|
+
await createOrg(sql, "test-org", "Test Org");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await sql.end();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function makeEvent(overrides: Partial<PolarisEvent> = {}): PolarisEvent {
|
|
47
|
+
return {
|
|
48
|
+
id: crypto.randomUUID(),
|
|
49
|
+
project: "pj",
|
|
50
|
+
session: "fxm",
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
source: "hook",
|
|
53
|
+
sender: "user:manu",
|
|
54
|
+
payload: {
|
|
55
|
+
hook_event_name: "UserPromptSubmit",
|
|
56
|
+
session_id: "s1",
|
|
57
|
+
prompt: "hello",
|
|
58
|
+
},
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("orgs", () => {
|
|
64
|
+
test("create and retrieve an org", async () => {
|
|
65
|
+
const org = await getOrg(sql, "test-org");
|
|
66
|
+
expect(org).not.toBeNull();
|
|
67
|
+
expect(org!.name).toBe("Test Org");
|
|
68
|
+
expect(org!.created_at).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("duplicate org id throws", async () => {
|
|
72
|
+
expect(createOrg(sql, "test-org", "Duplicate")).rejects.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("get nonexistent org returns null", async () => {
|
|
76
|
+
expect(await getOrg(sql, "nope")).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("get org by domain", async () => {
|
|
80
|
+
await createOrg(sql, "domain-org", "Domain Org", "example.com");
|
|
81
|
+
const org = await getOrgByDomain(sql, "example.com");
|
|
82
|
+
expect(org).not.toBeNull();
|
|
83
|
+
expect(org!.id).toBe("domain-org");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("get org by domain returns null for unknown domain", async () => {
|
|
87
|
+
expect(await getOrgByDomain(sql, "unknown.com")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("set org slack credentials", async () => {
|
|
91
|
+
await setOrgSlack(sql, "test-org", "T123", "xoxb-token");
|
|
92
|
+
const org = await getOrg(sql, "test-org");
|
|
93
|
+
expect(org!.slack_team_id).toBe("T123");
|
|
94
|
+
expect(org!.slack_bot_token).toBe("xoxb-token");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("users", () => {
|
|
99
|
+
test("create and retrieve a user", async () => {
|
|
100
|
+
const user = await createUser(sql, "u1", "manu@test.com", "Manu", "test-org", "user:manu");
|
|
101
|
+
expect(user.email).toBe("manu@test.com");
|
|
102
|
+
expect(user.org_id).toBe("test-org");
|
|
103
|
+
expect(user.participant_id).toBe("user:manu");
|
|
104
|
+
|
|
105
|
+
const retrieved = await getUser(sql, "u1");
|
|
106
|
+
expect(retrieved).not.toBeNull();
|
|
107
|
+
expect(retrieved!.name).toBe("Manu");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("get user by email", async () => {
|
|
111
|
+
await createUser(sql, "u2", "krishna@test.com", "Krishna", "test-org", "user:krishna");
|
|
112
|
+
const user = await getUserByEmail(sql, "krishna@test.com");
|
|
113
|
+
expect(user).not.toBeNull();
|
|
114
|
+
expect(user!.id).toBe("u2");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("get nonexistent user returns null", async () => {
|
|
118
|
+
expect(await getUser(sql, "nope")).toBeNull();
|
|
119
|
+
expect(await getUserByEmail(sql, "nope@test.com")).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("duplicate email throws", async () => {
|
|
123
|
+
await createUser(sql, "u3", "dupe@test.com", "User A", "test-org", "user:a");
|
|
124
|
+
expect(createUser(sql, "u4", "dupe@test.com", "User B", "test-org", "user:b")).rejects.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("upsert user updates existing", async () => {
|
|
128
|
+
await createUser(sql, "u5", "upsert@test.com", "Old Name", "test-org", "user:old");
|
|
129
|
+
const updated = await upsertUser(sql, "u5-new", "upsert@test.com", "New Name", "test-org", "user:new");
|
|
130
|
+
expect(updated.name).toBe("New Name");
|
|
131
|
+
expect(updated.participant_id).toBe("user:new");
|
|
132
|
+
|
|
133
|
+
const fetched = await getUserByEmail(sql, "upsert@test.com");
|
|
134
|
+
expect(fetched!.name).toBe("New Name");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("projects", () => {
|
|
139
|
+
test("create and retrieve a project", async () => {
|
|
140
|
+
const project = await createProject(sql, "test-org", "pj");
|
|
141
|
+
expect(project.name).toBe("pj");
|
|
142
|
+
expect(project.created_at).toBeDefined();
|
|
143
|
+
|
|
144
|
+
const retrieved = await getProject(sql, "test-org", "pj");
|
|
145
|
+
expect(retrieved).not.toBeNull();
|
|
146
|
+
expect(retrieved!.name).toBe("pj");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("duplicate project name throws", async () => {
|
|
150
|
+
await createProject(sql, "test-org", "pj");
|
|
151
|
+
expect(createProject(sql, "test-org", "pj")).rejects.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("get nonexistent project returns null", async () => {
|
|
155
|
+
expect(await getProject(sql, "test-org", "nope")).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("sessions", () => {
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
await createProject(sql, "test-org", "pj");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("create and retrieve a session", async () => {
|
|
165
|
+
const session = await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
166
|
+
expect(session.name).toBe("fxm");
|
|
167
|
+
expect(session.project).toBe("pj");
|
|
168
|
+
expect(session.driver).toBe("user:manu");
|
|
169
|
+
|
|
170
|
+
const retrieved = await getSession(sql, "test-org", "pj", "fxm");
|
|
171
|
+
expect(retrieved).not.toBeNull();
|
|
172
|
+
expect(retrieved!.driver).toBe("user:manu");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("create session with null driver", async () => {
|
|
176
|
+
const session = await createSession(sql, "test-org", "pj", "open-session", null);
|
|
177
|
+
expect(session.driver).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("duplicate session name in same project throws", async () => {
|
|
181
|
+
await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
182
|
+
expect(createSession(sql, "test-org", "pj", "fxm", "user:krishna")).rejects.toThrow();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("same session name in different projects is fine", async () => {
|
|
186
|
+
await createProject(sql, "test-org", "pj2");
|
|
187
|
+
await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
188
|
+
const s2 = await createSession(sql, "test-org", "pj2", "fxm", "user:krishna");
|
|
189
|
+
expect(s2.driver).toBe("user:krishna");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("get nonexistent session returns null", async () => {
|
|
193
|
+
expect(await getSession(sql, "test-org", "pj", "nope")).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("set driver", async () => {
|
|
197
|
+
await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
198
|
+
await setDriver(sql, "test-org", "pj", "fxm", "user:krishna");
|
|
199
|
+
const session = await getSession(sql, "test-org", "pj", "fxm");
|
|
200
|
+
expect(session!.driver).toBe("user:krishna");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("clear driver", async () => {
|
|
204
|
+
await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
205
|
+
await clearDriver(sql, "test-org", "pj", "fxm");
|
|
206
|
+
const session = await getSession(sql, "test-org", "pj", "fxm");
|
|
207
|
+
expect(session!.driver).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("events", () => {
|
|
212
|
+
beforeEach(async () => {
|
|
213
|
+
await createProject(sql, "test-org", "pj");
|
|
214
|
+
await createSession(sql, "test-org", "pj", "fxm", "user:manu");
|
|
215
|
+
await createSession(sql, "test-org", "pj", "fxk", "user:krishna");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("push and retrieve events by project", async () => {
|
|
219
|
+
const e1 = makeEvent({ session: "fxm", timestamp: "2026-06-05T10:00:00.000Z" });
|
|
220
|
+
const e2 = makeEvent({ session: "fxk", sender: "user:krishna", timestamp: "2026-06-05T10:01:00.000Z" });
|
|
221
|
+
await pushEvent(sql, "test-org", e1);
|
|
222
|
+
await pushEvent(sql, "test-org", e2);
|
|
223
|
+
|
|
224
|
+
const events = await getProjectEvents(sql, "test-org", "pj");
|
|
225
|
+
expect(events).toHaveLength(2);
|
|
226
|
+
expect(events[0].session).toBe("fxm");
|
|
227
|
+
expect(events[1].session).toBe("fxk");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("retrieve events by session", async () => {
|
|
231
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxm" }));
|
|
232
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxk", sender: "user:krishna" }));
|
|
233
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxm" }));
|
|
234
|
+
|
|
235
|
+
const fxmEvents = await getSessionEvents(sql, "test-org", "pj", "fxm");
|
|
236
|
+
expect(fxmEvents).toHaveLength(2);
|
|
237
|
+
|
|
238
|
+
const fxkEvents = await getSessionEvents(sql, "test-org", "pj", "fxk");
|
|
239
|
+
expect(fxkEvents).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("events are ordered by timestamp", async () => {
|
|
243
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxm", timestamp: "2026-06-05T10:02:00.000Z" }));
|
|
244
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxm", timestamp: "2026-06-05T10:00:00.000Z" }));
|
|
245
|
+
await pushEvent(sql, "test-org", makeEvent({ session: "fxm", timestamp: "2026-06-05T10:01:00.000Z" }));
|
|
246
|
+
|
|
247
|
+
const events = await getSessionEvents(sql, "test-org", "pj", "fxm");
|
|
248
|
+
expect(events[0].timestamp).toBe("2026-06-05T10:00:00.000Z");
|
|
249
|
+
expect(events[1].timestamp).toBe("2026-06-05T10:01:00.000Z");
|
|
250
|
+
expect(events[2].timestamp).toBe("2026-06-05T10:02:00.000Z");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("getEventsSince filters by timestamp", async () => {
|
|
254
|
+
await pushEvent(sql, "test-org", makeEvent({ timestamp: "2026-06-05T10:00:00.000Z" }));
|
|
255
|
+
await pushEvent(sql, "test-org", makeEvent({ timestamp: "2026-06-05T10:01:00.000Z" }));
|
|
256
|
+
await pushEvent(sql, "test-org", makeEvent({ timestamp: "2026-06-05T10:02:00.000Z" }));
|
|
257
|
+
|
|
258
|
+
const events = await getEventsSince(sql, "test-org", "pj", "2026-06-05T10:00:30.000Z");
|
|
259
|
+
expect(events).toHaveLength(2);
|
|
260
|
+
expect(events[0].timestamp).toBe("2026-06-05T10:01:00.000Z");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("payload round-trips through JSONB", async () => {
|
|
264
|
+
const event = makeEvent({
|
|
265
|
+
source: "inject",
|
|
266
|
+
payload: {
|
|
267
|
+
type: "inject" as const,
|
|
268
|
+
content: "Use RS256",
|
|
269
|
+
sender: "user:krishna" as const,
|
|
270
|
+
target: "fxm",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
await pushEvent(sql, "test-org", event);
|
|
274
|
+
|
|
275
|
+
const events = await getSessionEvents(sql, "test-org", "pj", "fxm");
|
|
276
|
+
expect(events[0].payload).toEqual(event.payload);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("empty project returns empty array", async () => {
|
|
280
|
+
expect(await getProjectEvents(sql, "test-org", "pj")).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
});
|