@lightupai/polaris 0.0.77 → 0.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ pushEvent,
8
+ getSessionEventsPage,
9
+ type Sql,
10
+ } from "../src/service/db";
11
+ import type { PolarisEvent } from "../src/types";
12
+
13
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
14
+
15
+ // Unique ids so this file is self-contained against a live Postgres.
16
+ const RUN = crypto.randomUUID().slice(0, 8);
17
+ const ORG = `pag-org-${RUN}`;
18
+ const PROJECT = `pj-pagination-${RUN}`;
19
+ const SESSION = "fx-page";
20
+ const TIE_SESSION = "fx-ties";
21
+ const TOTAL = 25;
22
+
23
+ let sql: Sql;
24
+ const insertedIds: string[] = [];
25
+ const tieIds: string[] = [];
26
+
27
+ function makeEvent(session: string, timestamp: string, prompt: string): PolarisEvent {
28
+ return {
29
+ id: crypto.randomUUID(),
30
+ project: PROJECT,
31
+ session,
32
+ timestamp,
33
+ source: "hook",
34
+ sender: "user:manu",
35
+ payload: {
36
+ hook_event_name: "UserPromptSubmit",
37
+ session_id: "cc-page",
38
+ prompt,
39
+ },
40
+ };
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ // Drop and recreate via createDb (mirrors tests/db.test.ts) so the schema —
45
+ // including the additive migrations — matches src/service/db.ts exactly.
46
+ sql = await createDb(DATABASE_URL);
47
+ await sql`DROP TABLE IF EXISTS plan_changes`;
48
+ await sql`DROP TABLE IF EXISTS events`;
49
+ await sql`DROP TABLE IF EXISTS sessions`;
50
+ await sql`DROP TABLE IF EXISTS projects`;
51
+ await sql`DROP TABLE IF EXISTS users`;
52
+ await sql`DROP TABLE IF EXISTS orgs`;
53
+ await sql.end();
54
+ sql = await createDb(DATABASE_URL);
55
+ await createOrg(sql, ORG, "Pagination Org");
56
+ await createProject(sql, ORG, PROJECT);
57
+ await createSession(sql, ORG, PROJECT, SESSION, "user:manu");
58
+ await createSession(sql, ORG, PROJECT, TIE_SESSION, "user:manu");
59
+
60
+ // 25 events with distinct millisecond timestamps, inserted oldest-first
61
+ const base = Date.parse("2026-06-10T10:00:00.000Z");
62
+ for (let i = 0; i < TOTAL; i++) {
63
+ const event = makeEvent(SESSION, new Date(base + i * 1000).toISOString(), `prompt ${i}`);
64
+ insertedIds.push(event.id);
65
+ await pushEvent(sql, ORG, event);
66
+ }
67
+
68
+ // 6 events sharing an identical timestamp — keyset must fall back to id ordering
69
+ const tieTs = "2026-06-10T12:00:00.000Z";
70
+ for (let i = 0; i < 6; i++) {
71
+ const event = makeEvent(TIE_SESSION, tieTs, `tie ${i}`);
72
+ tieIds.push(event.id);
73
+ await pushEvent(sql, ORG, event);
74
+ }
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await sql.end();
79
+ });
80
+
81
+ describe("getSessionEventsPage", () => {
82
+ test("returns at most `limit` events, newest first", async () => {
83
+ const { events } = await getSessionEventsPage(sql, ORG, PROJECT, SESSION, { limit: 10 });
84
+ expect(events).toHaveLength(10);
85
+ expect(events[0].payload).toHaveProperty("prompt", `prompt ${TOTAL - 1}`);
86
+ for (let i = 1; i < events.length; i++) {
87
+ expect(Date.parse(events[i - 1].timestamp)).toBeGreaterThanOrEqual(Date.parse(events[i].timestamp));
88
+ }
89
+ });
90
+
91
+ test("default limit returns everything when under 100, with null cursor", async () => {
92
+ const { events, nextCursor } = await getSessionEventsPage(sql, ORG, PROJECT, SESSION);
93
+ expect(events).toHaveLength(TOTAL);
94
+ expect(nextCursor).toBeNull();
95
+ });
96
+
97
+ test("nextCursor is the composite cursor of the oldest returned row", async () => {
98
+ const { events, nextCursor } = await getSessionEventsPage(sql, ORG, PROJECT, SESSION, { limit: 10 });
99
+ const oldest = events[events.length - 1];
100
+ expect(nextCursor).toBe(`${oldest.timestamp}|${oldest.id}`);
101
+ });
102
+
103
+ test("pages through all events with no duplicates or gaps", async () => {
104
+ const seen = new Set<string>();
105
+ const pageSizes: number[] = [];
106
+ let cursor: string | undefined;
107
+ let lastCursor: string | null = null;
108
+ while (true) {
109
+ const page = await getSessionEventsPage(sql, ORG, PROJECT, SESSION, { limit: 10, before: cursor });
110
+ if (page.events.length === 0) {
111
+ expect(page.nextCursor).toBeNull();
112
+ break;
113
+ }
114
+ pageSizes.push(page.events.length);
115
+ for (const event of page.events) {
116
+ expect(seen.has(event.id)).toBe(false); // no duplicates across pages
117
+ seen.add(event.id);
118
+ }
119
+ lastCursor = page.nextCursor;
120
+ if (!page.nextCursor) break;
121
+ cursor = page.nextCursor;
122
+ }
123
+ expect(pageSizes).toEqual([10, 10, 5]);
124
+ expect(lastCursor).toBeNull(); // short final page ends pagination
125
+ expect(seen.size).toBe(TOTAL);
126
+ expect([...seen].sort()).toEqual([...insertedIds].sort()); // no gaps
127
+ });
128
+
129
+ test("pages do not overlap and stay strictly older across the cursor boundary", async () => {
130
+ const page1 = await getSessionEventsPage(sql, ORG, PROJECT, SESSION, { limit: 10 });
131
+ const page2 = await getSessionEventsPage(sql, ORG, PROJECT, SESSION, { limit: 10, before: page1.nextCursor! });
132
+ const page1Ids = new Set(page1.events.map((e) => e.id));
133
+ for (const event of page2.events) {
134
+ expect(page1Ids.has(event.id)).toBe(false);
135
+ }
136
+ const oldestOfPage1 = page1.events[page1.events.length - 1];
137
+ expect(Date.parse(page2.events[0].timestamp)).toBeLessThan(Date.parse(oldestOfPage1.timestamp));
138
+ });
139
+
140
+ test("keyset is stable across identical timestamps (id tiebreak, no dupes/gaps)", async () => {
141
+ const seen = new Set<string>();
142
+ let cursor: string | undefined;
143
+ while (true) {
144
+ const page = await getSessionEventsPage(sql, ORG, PROJECT, TIE_SESSION, { limit: 2, before: cursor });
145
+ if (page.events.length === 0) {
146
+ expect(page.nextCursor).toBeNull();
147
+ break;
148
+ }
149
+ for (const event of page.events) {
150
+ expect(seen.has(event.id)).toBe(false);
151
+ seen.add(event.id);
152
+ }
153
+ if (!page.nextCursor) break;
154
+ cursor = page.nextCursor;
155
+ }
156
+ expect(seen.size).toBe(tieIds.length);
157
+ expect([...seen].sort()).toEqual([...tieIds].sort());
158
+ });
159
+
160
+ test("empty session returns no events and a null cursor", async () => {
161
+ const { events, nextCursor } = await getSessionEventsPage(sql, ORG, PROJECT, "fx-nothing-here");
162
+ expect(events).toEqual([]);
163
+ expect(nextCursor).toBeNull();
164
+ });
165
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test, afterAll } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Best-effort coverage of the `polaris recover` contract: read ~/.polaris/logs/*.jsonl,
7
+ // re-POST events missing upstream, print a scanned/missing/restored summary, and —
8
+ // crucially — never throw. The CLI runs as a subprocess with an isolated HOME so the
9
+ // real ~/.polaris is untouched. Until the cli agent's command is integrated, the
10
+ // tests detect its absence in src/cli/cli.ts and skip with a warning.
11
+
12
+ const CLI_PATH = join(import.meta.dir, "..", "src", "cli", "cli.ts");
13
+
14
+ let hasRecover = false;
15
+ try {
16
+ hasRecover = /\brecover\b/.test(await readFile(CLI_PATH, "utf-8"));
17
+ } catch {
18
+ // CLI missing entirely — skip.
19
+ }
20
+ if (!hasRecover) {
21
+ console.warn("[recover.test] `polaris recover` not implemented yet — skipping (expected before cli-agent integration)");
22
+ }
23
+
24
+ const homes: string[] = [];
25
+
26
+ afterAll(async () => {
27
+ for (const home of homes) {
28
+ try { await rm(home, { recursive: true, force: true }); } catch { /* best-effort */ }
29
+ }
30
+ });
31
+
32
+ async function runRecover(jsonlLines: string[] | null): Promise<{ exitCode: number; output: string }> {
33
+ const home = await mkdtemp(join(tmpdir(), "polaris-recover-"));
34
+ homes.push(home);
35
+ if (jsonlLines !== null) {
36
+ const logDir = join(home, ".polaris", "logs");
37
+ await mkdir(logDir, { recursive: true });
38
+ await writeFile(join(logDir, "daemon-2026-06-01.jsonl"), jsonlLines.join("\n") + "\n");
39
+ }
40
+ const env = { ...(process.env as Record<string, string>) };
41
+ const proc = Bun.spawn([process.execPath, CLI_PATH, "recover"], {
42
+ env: {
43
+ ...env,
44
+ HOME: home,
45
+ // Unreachable API: recover is best-effort and must still exit cleanly.
46
+ POLARIS_SERVICE_URL: "http://127.0.0.1:9",
47
+ POLARIS_AUTH_TOKEN: "",
48
+ },
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ });
52
+ const [stdout, stderr, exitCode] = await Promise.all([
53
+ new Response(proc.stdout).text(),
54
+ new Response(proc.stderr).text(),
55
+ proc.exited,
56
+ ]);
57
+ return { exitCode, output: stdout + stderr };
58
+ }
59
+
60
+ describe("polaris recover", () => {
61
+ test.skipIf(!hasRecover)("exits cleanly with no logs present", async () => {
62
+ const { exitCode } = await runRecover(null);
63
+ expect(exitCode).toBe(0);
64
+ }, 30000);
65
+
66
+ test.skipIf(!hasRecover)("never throws on mixed/malformed log lines and an unreachable API", async () => {
67
+ const { exitCode, output } = await runRecover([
68
+ JSON.stringify({
69
+ t: "2026-06-01T10:00:00.000Z",
70
+ endpoint: "/events",
71
+ payload: { session_id: "cc-r1", hook_event_name: "UserPromptSubmit", prompt: "recover me" },
72
+ }),
73
+ "this line is not json {{{",
74
+ JSON.stringify({ t: "2026-06-01T10:01:00.000Z", endpoint: "/reply", payload: { ccSessionId: "cc-r1", message: "done" } }),
75
+ ]);
76
+ expect(exitCode).toBe(0);
77
+ // Contract: print a summary (scanned / missing / restored). Soft-check only —
78
+ // exact wording belongs to the cli agent.
79
+ if (!/scan|missing|restor|recover/i.test(output)) {
80
+ console.warn("[recover.test] no recognizable summary in output:", output.slice(0, 200));
81
+ }
82
+ }, 30000);
83
+ });
@@ -0,0 +1,103 @@
1
+ // APPROACH CHOSEN (per the tests contract): the daemon test harness (startDaemon +
2
+ // startServer) is usable exactly as in tests/daemon.test.ts, so this exercises the
3
+ // full /reply path rather than a db-level unit test. The contract under test:
4
+ // a daemon /reply must produce an event attributed to the session's AGENT identity
5
+ // (mapping.agent), not the human driver (mapping.user) — replies are authored by the
6
+ // agent on the user's behalf.
7
+ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
8
+ import { startServer } from "../src/service/server";
9
+ import { startDaemon } from "../src/daemon/daemon";
10
+ import type { Sql } from "../src/service/db";
11
+ import { resetTestData } from "./helpers";
12
+
13
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
14
+
15
+ let serviceUrl: string;
16
+ let daemonUrl: string;
17
+ let sql: Sql;
18
+ let stopService: () => Promise<void>;
19
+ let stopDaemon: () => void;
20
+
21
+ beforeAll(async () => {
22
+ const s = await startServer({ port: 0, databaseUrl: DATABASE_URL });
23
+ sql = s.sql;
24
+ stopService = s.stop;
25
+ serviceUrl = `http://localhost:${s.server.port}`;
26
+
27
+ process.env.POLARIS_SERVICE_URL = serviceUrl;
28
+ process.env.POLARIS_AUTH_TOKEN = "";
29
+ const d = startDaemon(0);
30
+ stopDaemon = d.stop;
31
+ daemonUrl = `http://127.0.0.1:${d.server.port}`;
32
+ });
33
+
34
+ afterAll(async () => {
35
+ stopDaemon();
36
+ await stopService();
37
+ });
38
+
39
+ beforeEach(async () => {
40
+ await resetTestData(sql);
41
+ // The daemon session registry is module-level state; clear any mappings left
42
+ // behind by other test files so /reply routes only via our own ccSessionId.
43
+ await post(daemonUrl, "/disconnect-all", {});
44
+ });
45
+
46
+ async function post(base: string, path: string, body: unknown) {
47
+ return fetch(`${base}${path}`, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify(body),
51
+ });
52
+ }
53
+
54
+ async function get(base: string, path: string) {
55
+ return fetch(`${base}${path}`);
56
+ }
57
+
58
+ describe("daemon /reply attribution", () => {
59
+ test("reply is attributed to the connected agent identity, not the user", async () => {
60
+ const connectRes = await post(daemonUrl, "/connect", {
61
+ ccSessionId: "cc-reply-attr",
62
+ project: "pj-reply",
63
+ session: "fx-reply",
64
+ user: "user:manu",
65
+ agent: "agent:replybot",
66
+ });
67
+ expect(connectRes.status).toBe(200);
68
+
69
+ const replyRes = await post(daemonUrl, "/reply", {
70
+ ccSessionId: "cc-reply-attr",
71
+ message: "Deployed the fix to staging",
72
+ });
73
+ expect(replyRes.status).toBe(200);
74
+
75
+ const messages = await get(serviceUrl, "/projects/pj-reply/sessions/fx-reply/messages");
76
+ const body = await messages.json();
77
+ expect(body).toHaveLength(1);
78
+ expect(body[0].payload.stop_response).toBe("Deployed the fix to staging");
79
+ expect(body[0].sender).toBe("agent:replybot"); // the agent identity from /connect
80
+ expect(body[0].sender).not.toBe("user:manu"); // never the human driver
81
+ });
82
+
83
+ test("reply uses the default agent identity when none was given at connect", async () => {
84
+ await post(daemonUrl, "/connect", {
85
+ ccSessionId: "cc-reply-default",
86
+ project: "pj-reply",
87
+ session: "fx-reply-default",
88
+ user: "user:krishna",
89
+ });
90
+
91
+ const replyRes = await post(daemonUrl, "/reply", {
92
+ ccSessionId: "cc-reply-default",
93
+ message: "Tests are green",
94
+ });
95
+ expect(replyRes.status).toBe(200);
96
+
97
+ const messages = await get(serviceUrl, "/projects/pj-reply/sessions/fx-reply-default/messages");
98
+ const body = await messages.json();
99
+ expect(body).toHaveLength(1);
100
+ expect(body[0].sender).toBe("agent:claude"); // daemon's default agent identity
101
+ expect(body[0].sender).not.toBe("user:krishna");
102
+ });
103
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ pushEvent,
8
+ searchEvents,
9
+ addAnnotation,
10
+ listDecisions,
11
+ setProjectVisibility,
12
+ addProjectMember,
13
+ type Sql,
14
+ } from "../src/service/db";
15
+ import type { PolarisEvent } from "../src/types";
16
+
17
+ // Regression test for the ACL bypass: search / decisions WITHOUT a project filter
18
+ // must not leak content from 'members'-restricted projects to non-members.
19
+
20
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
21
+
22
+ const RUN = crypto.randomUUID().slice(0, 8);
23
+ const ORG = `sacl-org-${RUN}`;
24
+ const OPEN_PROJECT = `pj-sacl-open-${RUN}`;
25
+ const LOCKED_PROJECT = `pj-sacl-locked-${RUN}`;
26
+ const MEMBER = `user:member-${RUN}`;
27
+ const OUTSIDER = `user:outsider-${RUN}`;
28
+
29
+ // Distinctive single-token search terms so each match is unambiguous.
30
+ const OPEN_TERM = "pelican";
31
+ const LOCKED_TERM = "narwhal";
32
+
33
+ let sql: Sql;
34
+
35
+ function ev(project: string, session: string, ts: string, prompt: string): PolarisEvent {
36
+ return {
37
+ id: crypto.randomUUID(),
38
+ project,
39
+ session,
40
+ timestamp: ts,
41
+ source: "hook",
42
+ sender: "user:manu",
43
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt },
44
+ };
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ sql = await createDb(DATABASE_URL);
49
+ await sql`DROP TABLE IF EXISTS plan_changes`;
50
+ await sql`DROP TABLE IF EXISTS annotations`;
51
+ await sql`DROP TABLE IF EXISTS events`;
52
+ await sql`DROP TABLE IF EXISTS sessions`;
53
+ await sql`DROP TABLE IF EXISTS projects`;
54
+ await sql`DROP TABLE IF EXISTS users`;
55
+ await sql`DROP TABLE IF EXISTS orgs`;
56
+ await sql.end();
57
+ sql = await createDb(DATABASE_URL);
58
+
59
+ await createOrg(sql, ORG, "Search ACL Org");
60
+ await createProject(sql, ORG, OPEN_PROJECT);
61
+ await createProject(sql, ORG, LOCKED_PROJECT);
62
+ await createSession(sql, ORG, OPEN_PROJECT, "s1", "user:manu");
63
+ await createSession(sql, ORG, LOCKED_PROJECT, "s1", "user:manu");
64
+
65
+ await pushEvent(sql, ORG, ev(OPEN_PROJECT, "s1", "2026-06-10T10:00:00.000Z", `the ${OPEN_TERM} swims`));
66
+ await pushEvent(sql, ORG, ev(LOCKED_PROJECT, "s1", "2026-06-10T10:01:00.000Z", `the ${LOCKED_TERM} dives`));
67
+
68
+ await addAnnotation(sql, ORG, { project: OPEN_PROJECT, session: "s1", participant_id: "user:manu", kind: "decision", value: "use postgres" });
69
+ await addAnnotation(sql, ORG, { project: LOCKED_PROJECT, session: "s1", participant_id: "user:manu", kind: "decision", value: "use kafka" });
70
+
71
+ // Lock the second project and add MEMBER (OUTSIDER is intentionally not added).
72
+ await setProjectVisibility(sql, ORG, LOCKED_PROJECT, "members");
73
+ await addProjectMember(sql, ORG, LOCKED_PROJECT, MEMBER, "editor");
74
+ });
75
+
76
+ afterAll(async () => {
77
+ await sql.end();
78
+ });
79
+
80
+ describe("search respects project ACLs (no project filter)", () => {
81
+ test("outsider cannot find events in a 'members'-restricted project", async () => {
82
+ const open = await searchEvents(sql, ORG, { q: OPEN_TERM, participantId: OUTSIDER });
83
+ expect(open.results.length).toBeGreaterThan(0); // open project still searchable
84
+
85
+ const locked = await searchEvents(sql, ORG, { q: LOCKED_TERM, participantId: OUTSIDER });
86
+ expect(locked.results).toHaveLength(0); // restricted project content NOT leaked
87
+ });
88
+
89
+ test("member can find events in the restricted project", async () => {
90
+ const locked = await searchEvents(sql, ORG, { q: LOCKED_TERM, participantId: MEMBER });
91
+ expect(locked.results.length).toBeGreaterThan(0);
92
+ });
93
+
94
+ test("no participant (default-open / anonymous) sees everything", async () => {
95
+ const locked = await searchEvents(sql, ORG, { q: LOCKED_TERM });
96
+ expect(locked.results.length).toBeGreaterThan(0);
97
+ });
98
+ });
99
+
100
+ describe("decisions respect project ACLs (no project filter)", () => {
101
+ test("outsider does not see decisions from a restricted project", async () => {
102
+ const decisions = await listDecisions(sql, ORG, { participantId: OUTSIDER });
103
+ const projects = decisions.map((d) => d.project);
104
+ expect(projects).toContain(OPEN_PROJECT);
105
+ expect(projects).not.toContain(LOCKED_PROJECT);
106
+ });
107
+
108
+ test("member sees decisions from the restricted project", async () => {
109
+ const decisions = await listDecisions(sql, ORG, { participantId: MEMBER });
110
+ expect(decisions.map((d) => d.project)).toContain(LOCKED_PROJECT);
111
+ });
112
+
113
+ test("no participant (default-open) sees all decisions", async () => {
114
+ const decisions = await listDecisions(sql, ORG, {});
115
+ expect(decisions.map((d) => d.project)).toContain(LOCKED_PROJECT);
116
+ });
117
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ pushEvent,
8
+ searchEvents,
9
+ type Sql,
10
+ } from "../src/service/db";
11
+ import type { PolarisEvent } from "../src/types";
12
+
13
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
14
+
15
+ // Unique ids so this file is self-contained against a live Postgres.
16
+ const RUN = crypto.randomUUID().slice(0, 8);
17
+ const ORG = `search-org-${RUN}`;
18
+ const PROJECT_A = `pj-search-a-${RUN}`;
19
+ const PROJECT_B = `pj-search-b-${RUN}`;
20
+
21
+ let sql: Sql;
22
+
23
+ function baseEvent(project: string, session: string, timestamp: string): Pick<PolarisEvent, "id" | "project" | "session" | "timestamp"> {
24
+ return { id: crypto.randomUUID(), project, session, timestamp };
25
+ }
26
+
27
+ // Distinctive vocabulary per field so each match is unambiguous:
28
+ // "zeppelin" -> prompt (A/s1), inject content (A/s1), prompt (B/s1)
29
+ // "quasar" -> stop_response (A/s1)
30
+ // "labyrinth" -> last_assistant_message (A/s2)
31
+ const promptEvent: PolarisEvent = {
32
+ ...baseEvent(PROJECT_A, "s1", "2026-06-10T10:00:00.000Z"),
33
+ source: "hook",
34
+ sender: "user:manu",
35
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "deploy the zeppelin airship today" },
36
+ };
37
+ const stopEvent: PolarisEvent = {
38
+ ...baseEvent(PROJECT_A, "s1", "2026-06-10T10:01:00.000Z"),
39
+ source: "hook",
40
+ sender: "agent:claude",
41
+ payload: { hook_event_name: "Stop", session_id: "cc-s", stop_response: "the quasar pipeline output is ready" },
42
+ };
43
+ const lastMessageEvent: PolarisEvent = {
44
+ ...baseEvent(PROJECT_A, "s2", "2026-06-10T10:02:00.000Z"),
45
+ source: "hook",
46
+ sender: "agent:claude",
47
+ payload: { hook_event_name: "Stop", session_id: "cc-s", last_assistant_message: "finished the labyrinth refactor" },
48
+ };
49
+ const injectEvent: PolarisEvent = {
50
+ ...baseEvent(PROJECT_A, "s1", "2026-06-10T10:03:00.000Z"),
51
+ source: "inject",
52
+ sender: "user:priya",
53
+ payload: { type: "inject", content: "watch the zeppelin fuel budget", sender: "user:priya", target: "s1" },
54
+ };
55
+ const otherProjectEvent: PolarisEvent = {
56
+ ...baseEvent(PROJECT_B, "s1", "2026-06-10T10:04:00.000Z"),
57
+ source: "hook",
58
+ sender: "user:manu",
59
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "zeppelin maintenance checklist" },
60
+ };
61
+
62
+ beforeAll(async () => {
63
+ // Drop and recreate via createDb (mirrors tests/db.test.ts) so the schema —
64
+ // including the additive migrations — matches src/service/db.ts exactly.
65
+ sql = await createDb(DATABASE_URL);
66
+ await sql`DROP TABLE IF EXISTS plan_changes`;
67
+ await sql`DROP TABLE IF EXISTS events`;
68
+ await sql`DROP TABLE IF EXISTS sessions`;
69
+ await sql`DROP TABLE IF EXISTS projects`;
70
+ await sql`DROP TABLE IF EXISTS users`;
71
+ await sql`DROP TABLE IF EXISTS orgs`;
72
+ await sql.end();
73
+ sql = await createDb(DATABASE_URL);
74
+ await createOrg(sql, ORG, "Search Org");
75
+ await createProject(sql, ORG, PROJECT_A);
76
+ await createProject(sql, ORG, PROJECT_B);
77
+ await createSession(sql, ORG, PROJECT_A, "s1", "user:manu");
78
+ await createSession(sql, ORG, PROJECT_A, "s2", "user:manu");
79
+ await createSession(sql, ORG, PROJECT_B, "s1", "user:manu");
80
+ for (const event of [promptEvent, stopEvent, lastMessageEvent, injectEvent, otherProjectEvent]) {
81
+ await pushEvent(sql, ORG, event);
82
+ }
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await sql.end();
87
+ });
88
+
89
+ function ids(results: Array<{ event: PolarisEvent; snippet: string }>): string[] {
90
+ return results.map((r) => r.event.id).sort();
91
+ }
92
+
93
+ describe("searchEvents", () => {
94
+ test("matches a keyword in stop_response and returns a non-empty snippet", async () => {
95
+ const { results } = await searchEvents(sql, ORG, { q: "quasar" });
96
+ expect(results).toHaveLength(1);
97
+ expect(results[0].event.id).toBe(stopEvent.id);
98
+ expect(results[0].snippet.length).toBeGreaterThan(0);
99
+ expect(results[0].snippet.toLowerCase()).toContain("quasar");
100
+ });
101
+
102
+ test("matches across prompt, inject content, and last_assistant_message", async () => {
103
+ const zeppelin = await searchEvents(sql, ORG, { q: "zeppelin" });
104
+ expect(ids(zeppelin.results)).toEqual([promptEvent.id, injectEvent.id, otherProjectEvent.id].sort());
105
+
106
+ const labyrinth = await searchEvents(sql, ORG, { q: "labyrinth" });
107
+ expect(ids(labyrinth.results)).toEqual([lastMessageEvent.id]);
108
+ });
109
+
110
+ test("respects the project filter", async () => {
111
+ const { results } = await searchEvents(sql, ORG, { q: "zeppelin", project: PROJECT_A });
112
+ expect(ids(results)).toEqual([promptEvent.id, injectEvent.id].sort());
113
+ });
114
+
115
+ test("respects the session filter", async () => {
116
+ const inS1 = await searchEvents(sql, ORG, { q: "zeppelin", project: PROJECT_A, session: "s1" });
117
+ expect(ids(inS1.results)).toEqual([promptEvent.id, injectEvent.id].sort());
118
+
119
+ const inS2 = await searchEvents(sql, ORG, { q: "zeppelin", project: PROJECT_A, session: "s2" });
120
+ expect(inS2.results).toEqual([]);
121
+ });
122
+
123
+ test("respects the sender filter", async () => {
124
+ const { results } = await searchEvents(sql, ORG, { q: "zeppelin", sender: "user:priya" });
125
+ expect(ids(results)).toEqual([injectEvent.id]);
126
+ });
127
+
128
+ test("respects the source filter", async () => {
129
+ const injects = await searchEvents(sql, ORG, { q: "zeppelin", source: "inject" });
130
+ expect(ids(injects.results)).toEqual([injectEvent.id]);
131
+
132
+ const hooks = await searchEvents(sql, ORG, { q: "zeppelin", source: "hook" });
133
+ expect(ids(hooks.results)).toEqual([promptEvent.id, otherProjectEvent.id].sort());
134
+ });
135
+
136
+ test("respects the limit", async () => {
137
+ const { results } = await searchEvents(sql, ORG, { q: "zeppelin", limit: 1 });
138
+ expect(results).toHaveLength(1);
139
+ });
140
+
141
+ test("returns no results for a non-matching query", async () => {
142
+ const { results } = await searchEvents(sql, ORG, { q: "octopus" });
143
+ expect(results).toEqual([]);
144
+ });
145
+ });