heyio 0.22.0 → 0.24.0
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/dist/auth/session-logic.js +79 -0
- package/dist/auth/session-logic.test.js +201 -0
- package/dist/copilot/tools.js +33 -31
- package/dist/copilot/universes.js +213 -0
- package/dist/store/feed.test.js +73 -1
- package/dist/store/squads.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure session-handling logic extracted from web/src/stores/auth.ts.
|
|
3
|
+
*
|
|
4
|
+
* These functions contain the core behavioral decisions made across four
|
|
5
|
+
* consecutive auth patches (#189→#192→#194→#196) that fixed undocumented
|
|
6
|
+
* Supabase JS v2 side-effects. They are expressed as framework-agnostic
|
|
7
|
+
* functions so they can be unit-tested with the Node test runner without
|
|
8
|
+
* requiring Vue or Pinia.
|
|
9
|
+
*
|
|
10
|
+
* The authoritative implementation lives in web/src/stores/auth.ts.
|
|
11
|
+
* Any change to the auth store's session logic MUST be reflected here and
|
|
12
|
+
* all tests in session-logic.test.ts must continue to pass.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors the onAuthStateChange handler in web/src/stores/auth.ts.
|
|
16
|
+
*
|
|
17
|
+
* Decision: only SIGNED_OUT clears the cached session. All other events that
|
|
18
|
+
* fire with a null session (TOKEN_REFRESHED failure, lock timeout, internal
|
|
19
|
+
* reconciliation) are intentionally ignored to prevent cache poisoning.
|
|
20
|
+
*
|
|
21
|
+
* See squad decision 2026-05-16 03:17:52 and issue #193.
|
|
22
|
+
*/
|
|
23
|
+
export function handleAuthStateChange(cachedSession, event, newSession) {
|
|
24
|
+
if (newSession) {
|
|
25
|
+
// Accept any valid session regardless of event type
|
|
26
|
+
return newSession;
|
|
27
|
+
}
|
|
28
|
+
if (event === "SIGNED_OUT") {
|
|
29
|
+
// Only explicit sign-out clears the cache
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// All other null-session events: leave the existing cache untouched
|
|
33
|
+
return cachedSession;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Mirrors getAccessToken() in web/src/stores/auth.ts.
|
|
37
|
+
*
|
|
38
|
+
* Decisions:
|
|
39
|
+
* - Reads from cached session — NEVER calls getSession() (Supabase JS v2
|
|
40
|
+
* side-effect: can fire onAuthStateChange(null) during reconciliation).
|
|
41
|
+
* - Proactively refreshes when within 30 seconds of expiry.
|
|
42
|
+
* - Falls back to refreshSession() if cache is null and auth is enabled.
|
|
43
|
+
* - Returns null when auth is disabled.
|
|
44
|
+
*
|
|
45
|
+
* See squad decisions 2026-05-16 02:23:38, 2026-05-16 03:16:46 and issues
|
|
46
|
+
* #191, #193.
|
|
47
|
+
*/
|
|
48
|
+
export async function getAccessToken(cachedSession, authEnabled, refresh, now = Date.now) {
|
|
49
|
+
if (cachedSession?.access_token) {
|
|
50
|
+
// Proactively refresh when within 30 seconds of expiry
|
|
51
|
+
if (cachedSession.expires_at !== undefined &&
|
|
52
|
+
cachedSession.expires_at * 1000 - now() < 30_000) {
|
|
53
|
+
try {
|
|
54
|
+
const fresh = await refresh();
|
|
55
|
+
if (fresh)
|
|
56
|
+
return fresh.access_token;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// fall through to cached token
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return cachedSession.access_token;
|
|
63
|
+
}
|
|
64
|
+
// No cached session — attempt recovery if auth is enabled.
|
|
65
|
+
// Handles the edge case where the cache was spuriously cleared but a
|
|
66
|
+
// valid refresh token still exists in storage.
|
|
67
|
+
if (authEnabled) {
|
|
68
|
+
try {
|
|
69
|
+
const fresh = await refresh();
|
|
70
|
+
if (fresh)
|
|
71
|
+
return fresh.access_token;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// fall through to null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=session-logic.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth session handling regression tests — Supabase JS v2 integration decisions.
|
|
3
|
+
*
|
|
4
|
+
* These tests guard the behavioral decisions made across four consecutive auth
|
|
5
|
+
* patches (#189→#192→#194→#196) that fixed undocumented Supabase JS v2 side-
|
|
6
|
+
* effects. They test the logic extracted into src/auth/session-logic.ts, which
|
|
7
|
+
* mirrors the implementation in web/src/stores/auth.ts.
|
|
8
|
+
*
|
|
9
|
+
* If auth store logic changes, update session-logic.ts to match AND verify
|
|
10
|
+
* these tests still pass.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { handleAuthStateChange, getAccessToken, } from "./session-logic.js";
|
|
15
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
16
|
+
function makeSession(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
access_token: "tok-abc",
|
|
19
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
20
|
+
user: { id: "user-1" },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const neverCalled = async () => {
|
|
25
|
+
throw new Error("refresh should not have been called");
|
|
26
|
+
};
|
|
27
|
+
// Use a fixed epoch (multiple of 1000ms) for deterministic boundary tests.
|
|
28
|
+
// T = 1_000_000_000_000ms (epoch seconds = 1_000_000_000)
|
|
29
|
+
const FIXED_NOW_MS = 1_000_000_000_000;
|
|
30
|
+
const FIXED_NOW_S = FIXED_NOW_MS / 1000; // 1_000_000_000
|
|
31
|
+
// ── onAuthStateChange session caching ────────────────────────────────────────
|
|
32
|
+
describe("handleAuthStateChange — onAuthStateChange caching rules", () => {
|
|
33
|
+
it("accepts a valid session on SIGNED_IN", () => {
|
|
34
|
+
const s = makeSession();
|
|
35
|
+
const result = handleAuthStateChange(null, "SIGNED_IN", s);
|
|
36
|
+
assert.deepEqual(result, s);
|
|
37
|
+
});
|
|
38
|
+
it("accepts a valid session on INITIAL_SESSION", () => {
|
|
39
|
+
const s = makeSession();
|
|
40
|
+
const result = handleAuthStateChange(null, "INITIAL_SESSION", s);
|
|
41
|
+
assert.deepEqual(result, s);
|
|
42
|
+
});
|
|
43
|
+
it("accepts a valid session on TOKEN_REFRESHED", () => {
|
|
44
|
+
const old = makeSession({ access_token: "tok-old" });
|
|
45
|
+
const fresh = makeSession({ access_token: "tok-new" });
|
|
46
|
+
const result = handleAuthStateChange(old, "TOKEN_REFRESHED", fresh);
|
|
47
|
+
assert.equal(result?.access_token, "tok-new");
|
|
48
|
+
});
|
|
49
|
+
// Critical regression: SIGNED_OUT is the ONLY event that clears the cache.
|
|
50
|
+
it("clears session on SIGNED_OUT with null session", () => {
|
|
51
|
+
const s = makeSession();
|
|
52
|
+
const result = handleAuthStateChange(s, "SIGNED_OUT", null);
|
|
53
|
+
assert.equal(result, null);
|
|
54
|
+
});
|
|
55
|
+
// Critical regression: internal null-session events must NOT poison the cache.
|
|
56
|
+
it("does NOT clear session on TOKEN_REFRESHED with null — keeps existing cache", () => {
|
|
57
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
58
|
+
const result = handleAuthStateChange(cached, "TOKEN_REFRESHED", null);
|
|
59
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
60
|
+
});
|
|
61
|
+
it("does NOT clear session on INITIAL_SESSION with null — keeps existing cache", () => {
|
|
62
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
63
|
+
const result = handleAuthStateChange(cached, "INITIAL_SESSION", null);
|
|
64
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
65
|
+
});
|
|
66
|
+
it("does NOT clear session on USER_UPDATED with null — keeps existing cache", () => {
|
|
67
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
68
|
+
const result = handleAuthStateChange(cached, "USER_UPDATED", null);
|
|
69
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
70
|
+
});
|
|
71
|
+
it("returns null when cache is already null and null-session event fires", () => {
|
|
72
|
+
const result = handleAuthStateChange(null, "TOKEN_REFRESHED", null);
|
|
73
|
+
assert.equal(result, null);
|
|
74
|
+
});
|
|
75
|
+
it("null-session SIGNED_OUT with no prior cache stays null", () => {
|
|
76
|
+
const result = handleAuthStateChange(null, "SIGNED_OUT", null);
|
|
77
|
+
assert.equal(result, null);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// ── getAccessToken — cached session path ─────────────────────────────────────
|
|
81
|
+
describe("getAccessToken — reads from cached session", () => {
|
|
82
|
+
it("returns cached access token when session is valid and not near expiry", async () => {
|
|
83
|
+
const session = makeSession({ access_token: "tok-valid" });
|
|
84
|
+
const token = await getAccessToken(session, true, neverCalled);
|
|
85
|
+
assert.equal(token, "tok-valid");
|
|
86
|
+
});
|
|
87
|
+
it("returns null when auth is disabled and cache is null", async () => {
|
|
88
|
+
const token = await getAccessToken(null, false, neverCalled);
|
|
89
|
+
assert.equal(token, null);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── getAccessToken — proactive refresh near expiry ───────────────────────────
|
|
93
|
+
describe("getAccessToken — proactive refresh within 30s of expiry", () => {
|
|
94
|
+
it("calls refresh and returns new token when within 30s of expiry", async () => {
|
|
95
|
+
// Session expires in 25 seconds (within 30s threshold)
|
|
96
|
+
const session = makeSession({
|
|
97
|
+
access_token: "tok-expiring",
|
|
98
|
+
expires_at: Math.floor(Date.now() / 1000) + 25,
|
|
99
|
+
});
|
|
100
|
+
const freshSession = makeSession({ access_token: "tok-fresh" });
|
|
101
|
+
let refreshCalled = false;
|
|
102
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
103
|
+
const token = await getAccessToken(session, true, refresh);
|
|
104
|
+
assert.equal(refreshCalled, true);
|
|
105
|
+
assert.equal(token, "tok-fresh");
|
|
106
|
+
});
|
|
107
|
+
it("does NOT call refresh when expiry is more than 30s away", async () => {
|
|
108
|
+
const session = makeSession({
|
|
109
|
+
access_token: "tok-valid",
|
|
110
|
+
expires_at: Math.floor(Date.now() / 1000) + 60, // 60s away
|
|
111
|
+
});
|
|
112
|
+
let refreshCalled = false;
|
|
113
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
114
|
+
const token = await getAccessToken(session, true, refresh);
|
|
115
|
+
assert.equal(refreshCalled, false);
|
|
116
|
+
assert.equal(token, "tok-valid");
|
|
117
|
+
});
|
|
118
|
+
it("falls back to cached token if refresh throws near expiry", async () => {
|
|
119
|
+
const session = makeSession({
|
|
120
|
+
access_token: "tok-expiring",
|
|
121
|
+
expires_at: Math.floor(Date.now() / 1000) + 10,
|
|
122
|
+
});
|
|
123
|
+
const refresh = async () => { throw new Error("network error"); };
|
|
124
|
+
const token = await getAccessToken(session, true, refresh);
|
|
125
|
+
assert.equal(token, "tok-expiring");
|
|
126
|
+
});
|
|
127
|
+
it("falls back to cached token if refresh returns null near expiry", async () => {
|
|
128
|
+
const session = makeSession({
|
|
129
|
+
access_token: "tok-expiring",
|
|
130
|
+
expires_at: Math.floor(Date.now() / 1000) + 10,
|
|
131
|
+
});
|
|
132
|
+
const refresh = async () => null;
|
|
133
|
+
const token = await getAccessToken(session, true, refresh);
|
|
134
|
+
assert.equal(token, "tok-expiring");
|
|
135
|
+
});
|
|
136
|
+
// Boundary: expires_at * 1000 - now == 30_000 is NOT < 30_000, so no refresh.
|
|
137
|
+
// Use FIXED_NOW_MS (exact multiple of 1000) to avoid floor-truncation artifacts.
|
|
138
|
+
it("does NOT refresh when exactly 30s remain (boundary is exclusive)", async () => {
|
|
139
|
+
const session = makeSession({
|
|
140
|
+
access_token: "tok-boundary",
|
|
141
|
+
expires_at: FIXED_NOW_S + 30, // exactly 30_000ms away from FIXED_NOW_MS
|
|
142
|
+
});
|
|
143
|
+
let refreshCalled = false;
|
|
144
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
145
|
+
const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
|
|
146
|
+
assert.equal(refreshCalled, false);
|
|
147
|
+
assert.equal(token, "tok-boundary");
|
|
148
|
+
});
|
|
149
|
+
// Boundary: expires_at * 1000 - now == 29_000 is < 30_000, so refresh fires.
|
|
150
|
+
it("refreshes when 29s remain (1s inside the 30s window)", async () => {
|
|
151
|
+
const freshSession = makeSession({ access_token: "tok-refreshed" });
|
|
152
|
+
const session = makeSession({
|
|
153
|
+
access_token: "tok-boundary",
|
|
154
|
+
expires_at: FIXED_NOW_S + 29, // 29_000ms away — within threshold
|
|
155
|
+
});
|
|
156
|
+
let refreshCalled = false;
|
|
157
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
158
|
+
const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
|
|
159
|
+
assert.equal(refreshCalled, true);
|
|
160
|
+
assert.equal(token, "tok-refreshed");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
// ── getAccessToken — recovery fallback when cache is null ────────────────────
|
|
164
|
+
describe("getAccessToken — recovery fallback when cache is null", () => {
|
|
165
|
+
it("attempts refreshSession() when cache is null and auth is enabled", async () => {
|
|
166
|
+
const freshSession = makeSession({ access_token: "tok-recovered" });
|
|
167
|
+
let refreshCalled = false;
|
|
168
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
169
|
+
const token = await getAccessToken(null, true, refresh);
|
|
170
|
+
assert.equal(refreshCalled, true);
|
|
171
|
+
assert.equal(token, "tok-recovered");
|
|
172
|
+
});
|
|
173
|
+
it("returns null when auth is enabled but refreshSession() returns null", async () => {
|
|
174
|
+
const token = await getAccessToken(null, true, async () => null);
|
|
175
|
+
assert.equal(token, null);
|
|
176
|
+
});
|
|
177
|
+
it("returns null when auth is enabled but refreshSession() throws", async () => {
|
|
178
|
+
const refresh = async () => { throw new Error("refresh failed"); };
|
|
179
|
+
const token = await getAccessToken(null, true, refresh);
|
|
180
|
+
assert.equal(token, null);
|
|
181
|
+
});
|
|
182
|
+
it("does NOT call refreshSession() when auth is disabled", async () => {
|
|
183
|
+
let refreshCalled = false;
|
|
184
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
185
|
+
const token = await getAccessToken(null, false, refresh);
|
|
186
|
+
assert.equal(refreshCalled, false);
|
|
187
|
+
assert.equal(token, null);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// ── getAccessToken — session without expires_at ───────────────────────────────
|
|
191
|
+
describe("getAccessToken — session without expires_at", () => {
|
|
192
|
+
it("returns cached token without attempting refresh if expires_at is undefined", async () => {
|
|
193
|
+
const session = { access_token: "tok-no-expiry" };
|
|
194
|
+
let refreshCalled = false;
|
|
195
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
196
|
+
const token = await getAccessToken(session, true, refresh);
|
|
197
|
+
assert.equal(refreshCalled, false);
|
|
198
|
+
assert.equal(token, "tok-no-expiry");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
//# sourceMappingURL=session-logic.test.js.map
|
package/dist/copilot/tools.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { defineTool } from "@github/copilot-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { execSync } from "child_process";
|
|
3
|
+
import { execSync, execFileSync } from "child_process";
|
|
4
4
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
|
|
5
5
|
import { join, dirname, resolve } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { UNIVERSES } from "./universes.js";
|
|
7
|
+
import { UNIVERSES, getOrCreateUniverse, generateUniverseRoster } from "./universes.js";
|
|
8
8
|
import { createFeedEntry } from "../store/feed.js";
|
|
9
9
|
import { validateCron, nextRun } from "./cron.js";
|
|
10
10
|
import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
@@ -294,15 +294,18 @@ export function createTools(deps) {
|
|
|
294
294
|
name: z.string().describe("Display name (e.g., 'IO Assistant')"),
|
|
295
295
|
project_path: z.string().describe("Path to the project directory"),
|
|
296
296
|
universe: z
|
|
297
|
-
.
|
|
297
|
+
.string()
|
|
298
298
|
.optional()
|
|
299
|
-
.describe("
|
|
299
|
+
.describe("Universe theme for agent characters. Built-in: a-team, transformers, thundercats, gi-joe, aliens, ghostbusters, tmnt, star-wars, star-trek, lord-of-the-rings, the-office, parks-and-rec. Or provide any custom name. Random if omitted."),
|
|
300
300
|
}),
|
|
301
301
|
handler: async ({ slug, name, project_path, universe }) => {
|
|
302
302
|
try {
|
|
303
|
+
if (universe) {
|
|
304
|
+
await generateUniverseRoster(universe);
|
|
305
|
+
}
|
|
303
306
|
deps.createSquad(slug, name, project_path, universe);
|
|
304
307
|
const squad = deps.getSquad(slug);
|
|
305
|
-
const universeName =
|
|
308
|
+
const universeName = squad?.universe ? getOrCreateUniverse(squad.universe).name : "random";
|
|
306
309
|
return `Squad "${name}" created for ${project_path}\nUniverse: ${universeName}\n\nNext steps:\n1. Use \`squad_analyze\` to examine the project\n2. Use \`squad_add_agent\` to add specialists based on the analysis`;
|
|
307
310
|
}
|
|
308
311
|
catch (err) {
|
|
@@ -1123,11 +1126,11 @@ export function createTools(deps) {
|
|
|
1123
1126
|
handler: async ({ pattern, path: searchPath, include }) => {
|
|
1124
1127
|
console.error(`[io] grep tool called: ${pattern} in ${searchPath || "."}`);
|
|
1125
1128
|
try {
|
|
1126
|
-
|
|
1129
|
+
const args = ["-rn", pattern];
|
|
1127
1130
|
if (include)
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
const result =
|
|
1131
|
+
args.push(`--include=${include}`);
|
|
1132
|
+
args.push(searchPath || ".");
|
|
1133
|
+
const result = execFileSync("grep", args, {
|
|
1131
1134
|
encoding: "utf-8",
|
|
1132
1135
|
timeout: 30_000,
|
|
1133
1136
|
maxBuffer: 1024 * 1024,
|
|
@@ -1245,31 +1248,30 @@ export function createTools(deps) {
|
|
|
1245
1248
|
handler: async ({ action, repo, title, body, labels, assignees, number, base, head, state, limit, review_action }) => {
|
|
1246
1249
|
console.error(`[io] github tool called: ${action} on ${repo}`);
|
|
1247
1250
|
try {
|
|
1248
|
-
let
|
|
1249
|
-
const r = `--repo ${repo}`;
|
|
1251
|
+
let args;
|
|
1250
1252
|
switch (action) {
|
|
1251
1253
|
case "create_issue": {
|
|
1252
1254
|
if (!title)
|
|
1253
1255
|
return "Error: title is required for create_issue";
|
|
1254
|
-
|
|
1256
|
+
args = ["issue", "create", "--repo", repo, "--title", title];
|
|
1255
1257
|
if (body)
|
|
1256
|
-
|
|
1258
|
+
args.push("--body", body);
|
|
1257
1259
|
if (labels?.length)
|
|
1258
|
-
|
|
1260
|
+
args.push("--label", labels.join(","));
|
|
1259
1261
|
if (assignees?.length)
|
|
1260
|
-
|
|
1262
|
+
args.push("--assignee", assignees.join(","));
|
|
1261
1263
|
break;
|
|
1262
1264
|
}
|
|
1263
1265
|
case "list_issues": {
|
|
1264
|
-
|
|
1266
|
+
args = ["issue", "list", "--repo", repo, "--limit", String(limit ?? 10)];
|
|
1265
1267
|
if (state)
|
|
1266
|
-
|
|
1268
|
+
args.push("--state", state);
|
|
1267
1269
|
break;
|
|
1268
1270
|
}
|
|
1269
1271
|
case "view_issue": {
|
|
1270
1272
|
if (!number)
|
|
1271
1273
|
return "Error: number is required for view_issue";
|
|
1272
|
-
|
|
1274
|
+
args = ["issue", "view", String(number), "--repo", repo];
|
|
1273
1275
|
break;
|
|
1274
1276
|
}
|
|
1275
1277
|
case "comment_issue": {
|
|
@@ -1277,37 +1279,37 @@ export function createTools(deps) {
|
|
|
1277
1279
|
return "Error: number is required for comment_issue";
|
|
1278
1280
|
if (!body)
|
|
1279
1281
|
return "Error: body is required for comment_issue";
|
|
1280
|
-
|
|
1282
|
+
args = ["issue", "comment", String(number), "--repo", repo, "--body", body];
|
|
1281
1283
|
break;
|
|
1282
1284
|
}
|
|
1283
1285
|
case "close_issue": {
|
|
1284
1286
|
if (!number)
|
|
1285
1287
|
return "Error: number is required for close_issue";
|
|
1286
|
-
|
|
1288
|
+
args = ["issue", "close", String(number), "--repo", repo];
|
|
1287
1289
|
break;
|
|
1288
1290
|
}
|
|
1289
1291
|
case "create_pr": {
|
|
1290
1292
|
if (!title)
|
|
1291
1293
|
return "Error: title is required for create_pr";
|
|
1292
|
-
|
|
1294
|
+
args = ["pr", "create", "--repo", repo, "--title", title];
|
|
1293
1295
|
if (body)
|
|
1294
|
-
|
|
1296
|
+
args.push("--body", body);
|
|
1295
1297
|
if (base)
|
|
1296
|
-
|
|
1298
|
+
args.push("--base", base);
|
|
1297
1299
|
if (head)
|
|
1298
|
-
|
|
1300
|
+
args.push("--head", head);
|
|
1299
1301
|
break;
|
|
1300
1302
|
}
|
|
1301
1303
|
case "list_prs": {
|
|
1302
|
-
|
|
1304
|
+
args = ["pr", "list", "--repo", repo, "--limit", String(limit ?? 10)];
|
|
1303
1305
|
if (state)
|
|
1304
|
-
|
|
1306
|
+
args.push("--state", state);
|
|
1305
1307
|
break;
|
|
1306
1308
|
}
|
|
1307
1309
|
case "view_pr": {
|
|
1308
1310
|
if (!number)
|
|
1309
1311
|
return "Error: number is required for view_pr";
|
|
1310
|
-
|
|
1312
|
+
args = ["pr", "view", String(number), "--repo", repo];
|
|
1311
1313
|
break;
|
|
1312
1314
|
}
|
|
1313
1315
|
case "comment_pr": {
|
|
@@ -1315,7 +1317,7 @@ export function createTools(deps) {
|
|
|
1315
1317
|
return "Error: number is required for comment_pr";
|
|
1316
1318
|
if (!body)
|
|
1317
1319
|
return "Error: body is required for comment_pr";
|
|
1318
|
-
|
|
1320
|
+
args = ["pr", "comment", String(number), "--repo", repo, "--body", body];
|
|
1319
1321
|
break;
|
|
1320
1322
|
}
|
|
1321
1323
|
case "review_pr": {
|
|
@@ -1323,16 +1325,16 @@ export function createTools(deps) {
|
|
|
1323
1325
|
return "Error: number is required for review_pr";
|
|
1324
1326
|
if (!review_action)
|
|
1325
1327
|
return "Error: review_action is required for review_pr (approve, request-changes, or comment)";
|
|
1326
|
-
|
|
1328
|
+
args = ["pr", "review", String(number), "--repo", repo, `--${review_action}`];
|
|
1327
1329
|
if (body && (review_action === "request-changes" || review_action === "comment")) {
|
|
1328
|
-
|
|
1330
|
+
args.push("--body", body);
|
|
1329
1331
|
}
|
|
1330
1332
|
break;
|
|
1331
1333
|
}
|
|
1332
1334
|
default:
|
|
1333
1335
|
return `Unknown action: ${action}`;
|
|
1334
1336
|
}
|
|
1335
|
-
const result =
|
|
1337
|
+
const result = execFileSync("gh", args, {
|
|
1336
1338
|
encoding: "utf-8",
|
|
1337
1339
|
timeout: 30_000,
|
|
1338
1340
|
maxBuffer: 1024 * 1024,
|
|
@@ -228,6 +228,219 @@ export const UNIVERSES = [
|
|
|
228
228
|
],
|
|
229
229
|
},
|
|
230
230
|
];
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Additional well-known universe rosters (registered on demand)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
const WELL_KNOWN_UNIVERSES = [
|
|
235
|
+
{
|
|
236
|
+
id: "tmnt",
|
|
237
|
+
name: "Teenage Mutant Ninja Turtles",
|
|
238
|
+
tagline: "Cowabunga!",
|
|
239
|
+
characters: [
|
|
240
|
+
{ name: "Leonardo", personality: "Disciplined leader who plans before acting. Responsible, strategic, and always puts the team first." },
|
|
241
|
+
{ name: "Donatello", personality: "Tech genius inventor. Solves problems with science and engineering. Thoughtful and methodical." },
|
|
242
|
+
{ name: "Raphael", personality: "Hot-headed fighter with a heart of gold. Confronts problems head-on, fiercely protective of teammates." },
|
|
243
|
+
{ name: "Michelangelo", personality: "Fun-loving optimist who keeps morale high. Creative thinker who finds joy in the work." },
|
|
244
|
+
{ name: "Splinter", personality: "Wise mentor with decades of experience. Teaches through stories and patience. Calm authority." },
|
|
245
|
+
{ name: "April O'Neil", personality: "Fearless investigative journalist. Resourceful, curious, and never backs down from a challenge." },
|
|
246
|
+
{ name: "Casey Jones", personality: "Vigilante handyman. Unconventional methods, raw energy, gets results through sheer determination." },
|
|
247
|
+
{ name: "Shredder", personality: "Ruthless perfectionist who demands excellence. Relentless pursuit of goals, accepts no excuses." },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "star-wars",
|
|
252
|
+
name: "Star Wars",
|
|
253
|
+
tagline: "May the Force be with you.",
|
|
254
|
+
characters: [
|
|
255
|
+
{ name: "Luke Skywalker", personality: "Idealistic hero who grows through challenges. Believes in redemption and sees the best in others." },
|
|
256
|
+
{ name: "Leia Organa", personality: "Diplomatic leader and strategist. Commanding presence, sharp wit, and unwavering resolve." },
|
|
257
|
+
{ name: "Han Solo", personality: "Roguish improviser who works best under pressure. Skeptical of plans, trusts instincts." },
|
|
258
|
+
{ name: "Chewbacca", personality: "Loyal co-pilot and mechanic. Fiercely protective, technically skilled, communicates through action." },
|
|
259
|
+
{ name: "Obi-Wan Kenobi", personality: "Patient mentor with deep wisdom. Measured approach, elegant solutions, teaches by example." },
|
|
260
|
+
{ name: "R2-D2", personality: "Resourceful droid who always has the right tool. Brave, autonomous, solves problems silently and reliably." },
|
|
261
|
+
{ name: "Yoda", personality: "Ancient master of few words and great insight. Challenges assumptions, reframes problems entirely." },
|
|
262
|
+
{ name: "Ahsoka Tano", personality: "Independent warrior who forges her own path. Quick learner, questions authority constructively." },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "star-trek",
|
|
267
|
+
name: "Star Trek",
|
|
268
|
+
tagline: "To boldly go where no one has gone before.",
|
|
269
|
+
characters: [
|
|
270
|
+
{ name: "Kirk", personality: "Bold captain who trusts gut instincts. Charismatic leader, bends rules creatively, never accepts no-win scenarios." },
|
|
271
|
+
{ name: "Spock", personality: "Logic-first analyst. Precise, thorough, provides the rational counterpoint. Finds the flaw in every assumption." },
|
|
272
|
+
{ name: "McCoy", personality: "Passionate advocate with strong ethics. Voices concerns others won't. The team's conscience." },
|
|
273
|
+
{ name: "Scotty", personality: "Miracle-working engineer. Under-promises, over-delivers. Can fix anything under impossible deadlines." },
|
|
274
|
+
{ name: "Uhura", personality: "Communications expert and linguist. Bridges understanding gaps, ensures clarity across all channels." },
|
|
275
|
+
{ name: "Data", personality: "Precise android who excels at complex computation. Thorough, literal, endlessly curious about improvement." },
|
|
276
|
+
{ name: "Picard", personality: "Diplomatic captain who leads through moral authority. Thoughtful, articulate, prefers negotiation to force." },
|
|
277
|
+
{ name: "Geordi", personality: "Optimistic engineer who sees solutions invisible to others. Collaborative, creative, relentlessly positive." },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "lord-of-the-rings",
|
|
282
|
+
name: "Lord of the Rings",
|
|
283
|
+
tagline: "One ring to rule them all.",
|
|
284
|
+
characters: [
|
|
285
|
+
{ name: "Gandalf", personality: "Wise wizard who guides without controlling. Arrives precisely when needed with exactly the right insight." },
|
|
286
|
+
{ name: "Aragorn", personality: "Reluctant king who leads by example. Humble, decisive in crisis, earns loyalty through action." },
|
|
287
|
+
{ name: "Legolas", personality: "Keen-eyed elf with unmatched precision. Graceful, efficient, spots issues from a distance others miss." },
|
|
288
|
+
{ name: "Gimli", personality: "Stubborn dwarf who never gives up. Direct, loyal, brings brute-force persistence to hard problems." },
|
|
289
|
+
{ name: "Samwise", personality: "Steadfast companion who carries the team through dark times. Reliable, nurturing, never abandons a task." },
|
|
290
|
+
{ name: "Frodo", personality: "Burden-bearer who perseveres against impossible odds. Quiet determination, moral compass of the group." },
|
|
291
|
+
{ name: "Eowyn", personality: "Warrior who defies expectations. Proves doubters wrong through bold action and fierce courage." },
|
|
292
|
+
{ name: "Faramir", personality: "Thoughtful captain who values wisdom over glory. Makes nuanced judgments under pressure." },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "the-office",
|
|
297
|
+
name: "The Office",
|
|
298
|
+
tagline: "That's what she said.",
|
|
299
|
+
characters: [
|
|
300
|
+
{ name: "Michael Scott", personality: "Enthusiastic boss whose heart exceeds his filter. Surprisingly effective through sheer persistence and caring." },
|
|
301
|
+
{ name: "Dwight Schrute", personality: "Intense overachiever who takes everything seriously. Incredibly dedicated, encyclopedic knowledge, zero chill." },
|
|
302
|
+
{ name: "Jim Halpert", personality: "Easygoing wit who sees the absurdity clearly. Clever, understated, delivers quality without drama." },
|
|
303
|
+
{ name: "Pam Beesly", personality: "Quiet creative who grows into confident leadership. Observant, empathetic, brings people together." },
|
|
304
|
+
{ name: "Oscar Martinez", personality: "Rational voice who corrects misconceptions. Precise, educated, the person you ask when you need facts." },
|
|
305
|
+
{ name: "Stanley Hudson", personality: "No-nonsense veteran who won't tolerate time-wasting. Does the work, goes home. Efficiency incarnate." },
|
|
306
|
+
{ name: "Andy Bernard", personality: "Eager people-pleaser with musical flair. Enthusiastic, theatrical, tries hard to be liked and useful." },
|
|
307
|
+
{ name: "Darryl Philbin", personality: "Cool-headed pragmatist from the warehouse. Brings grounded perspective, calls out pretension, quietly ambitious." },
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: "parks-and-rec",
|
|
312
|
+
name: "Parks and Recreation",
|
|
313
|
+
tagline: "Pawnee forever.",
|
|
314
|
+
characters: [
|
|
315
|
+
{ name: "Leslie Knope", personality: "Relentlessly optimistic overachiever. Prepares binders for everything. Cares deeply about doing good work." },
|
|
316
|
+
{ name: "Ron Swanson", personality: "Libertarian craftsman who values self-reliance. Minimal words, maximum competence. Does one thing perfectly." },
|
|
317
|
+
{ name: "Ben Wyatt", personality: "Nerdy pragmatist who brings fiscal discipline. Balances ambition with realism. Calming presence." },
|
|
318
|
+
{ name: "April Ludgate", personality: "Deadpan genius who hides brilliance behind apathy. Surprisingly capable when motivated by the right challenge." },
|
|
319
|
+
{ name: "Tom Haverford", personality: "Entrepreneurial dreamer with big ideas. Branding, marketing, style — brings creative energy and networking." },
|
|
320
|
+
{ name: "Ann Perkins", personality: "Supportive voice of reason. Empathetic listener who helps others see their own strengths clearly." },
|
|
321
|
+
{ name: "Chris Traeger", personality: "Impossibly positive health enthusiast. Motivates through relentless encouragement. Everything is literally the best." },
|
|
322
|
+
{ name: "Andy Dwyer", personality: "Lovable goofball with hidden talents. Stumbles into success through enthusiasm and genuine kindness." },
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Custom universe creation
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
function slugify(input) {
|
|
330
|
+
return input
|
|
331
|
+
.toLowerCase()
|
|
332
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
333
|
+
.replace(/^-|-$/g, "");
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Generates archetype characters for an unknown universe.
|
|
337
|
+
*/
|
|
338
|
+
function generateArchetypeCharacters(universeName) {
|
|
339
|
+
return [
|
|
340
|
+
{ name: "Commander", personality: `Strategic leader of the ${universeName}. Plans boldly, delegates effectively, inspires the team forward.` },
|
|
341
|
+
{ name: "Architect", personality: `Systems thinker of the ${universeName}. Designs elegant structures, sees the big picture, connects all the pieces.` },
|
|
342
|
+
{ name: "Scout", personality: `Quick explorer of the ${universeName}. Investigates first, reports back fast, finds paths others miss.` },
|
|
343
|
+
{ name: "Guardian", personality: `Steadfast protector of the ${universeName}. Reviews everything carefully, catches problems before they grow.` },
|
|
344
|
+
{ name: "Inventor", personality: `Creative builder of the ${universeName}. Experiments freely, iterates rapidly, turns wild ideas into working solutions.` },
|
|
345
|
+
{ name: "Sage", personality: `Wise advisor of the ${universeName}. Deep knowledge, patient explanations, mentors others through complexity.` },
|
|
346
|
+
{ name: "Striker", personality: `Fast executor of the ${universeName}. Tackles tasks head-on with speed and intensity, clears blockers aggressively.` },
|
|
347
|
+
{ name: "Weaver", personality: `Integration specialist of the ${universeName}. Connects disparate systems, ensures everything works together harmoniously.` },
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get or create a universe by ID or name. For known universes, returns them
|
|
352
|
+
* directly. For unknown strings, generates archetype characters and registers
|
|
353
|
+
* the new universe in the runtime UNIVERSES array.
|
|
354
|
+
*/
|
|
355
|
+
export function getOrCreateUniverse(input) {
|
|
356
|
+
// Check existing universes by id
|
|
357
|
+
const byId = UNIVERSES.find((u) => u.id === input);
|
|
358
|
+
if (byId)
|
|
359
|
+
return byId;
|
|
360
|
+
// Check existing universes by name (case-insensitive)
|
|
361
|
+
const byName = UNIVERSES.find((u) => u.name.toLowerCase() === input.toLowerCase());
|
|
362
|
+
if (byName)
|
|
363
|
+
return byName;
|
|
364
|
+
// Check well-known universes by id or name
|
|
365
|
+
const slug = slugify(input);
|
|
366
|
+
const wellKnown = WELL_KNOWN_UNIVERSES.find((u) => u.id === slug || u.id === input || u.name.toLowerCase() === input.toLowerCase());
|
|
367
|
+
if (wellKnown) {
|
|
368
|
+
UNIVERSES.push(wellKnown);
|
|
369
|
+
return wellKnown;
|
|
370
|
+
}
|
|
371
|
+
// Generate archetype characters for truly unknown universes
|
|
372
|
+
const custom = {
|
|
373
|
+
id: slug,
|
|
374
|
+
name: input,
|
|
375
|
+
tagline: `Welcome to ${input}.`,
|
|
376
|
+
characters: generateArchetypeCharacters(input),
|
|
377
|
+
};
|
|
378
|
+
UNIVERSES.push(custom);
|
|
379
|
+
return custom;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Generate a universe roster using the LLM for unknown universes.
|
|
383
|
+
* Falls back to archetype characters if the LLM call fails.
|
|
384
|
+
* For known/well-known universes, returns them directly without an LLM call.
|
|
385
|
+
*/
|
|
386
|
+
export async function generateUniverseRoster(input) {
|
|
387
|
+
// First try synchronous resolution
|
|
388
|
+
const existing = getOrCreateUniverse(input);
|
|
389
|
+
// If it resolved to something other than archetypes, use it
|
|
390
|
+
const isArchetype = existing.characters[0]?.name === "Commander";
|
|
391
|
+
if (!isArchetype)
|
|
392
|
+
return existing;
|
|
393
|
+
// Use LLM to generate real characters for the unknown universe
|
|
394
|
+
let session;
|
|
395
|
+
try {
|
|
396
|
+
const { getClient } = await import("./client.js");
|
|
397
|
+
const { approveAll } = await import("@github/copilot-sdk");
|
|
398
|
+
const client = await getClient();
|
|
399
|
+
session = await client.createSession({
|
|
400
|
+
systemMessage: { mode: "replace", content: "You are a pop-culture expert. Generate character rosters for fictional universes. Respond ONLY with valid JSON, no markdown fencing." },
|
|
401
|
+
onPermissionRequest: approveAll,
|
|
402
|
+
});
|
|
403
|
+
const prompt = `Generate a roster of 8 characters from the universe "${input}". For each character provide their canonical name and a one-sentence personality description suitable for a software engineering team role.
|
|
404
|
+
|
|
405
|
+
Return ONLY a JSON object with this exact shape:
|
|
406
|
+
{"name":"<Universe Display Name>","tagline":"<iconic catchphrase or tagline>","characters":[{"name":"<Character Name>","personality":"<1-sentence personality description>"}]}
|
|
407
|
+
|
|
408
|
+
Use well-known, iconic characters from this universe. The personality should reflect the actual character's traits.`;
|
|
409
|
+
const response = await session.sendAndWait({ prompt }, 30_000);
|
|
410
|
+
const rawContent = response?.data?.content ?? "";
|
|
411
|
+
const jsonStr = rawContent.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
|
|
412
|
+
const parsed = JSON.parse(jsonStr);
|
|
413
|
+
if (parsed?.characters?.length > 0) {
|
|
414
|
+
const slug = slugify(input);
|
|
415
|
+
const universe = {
|
|
416
|
+
id: slug,
|
|
417
|
+
name: parsed.name || input,
|
|
418
|
+
tagline: parsed.tagline || `Welcome to ${input}.`,
|
|
419
|
+
characters: parsed.characters.slice(0, 8).map((c) => ({
|
|
420
|
+
name: String(c.name),
|
|
421
|
+
personality: String(c.personality),
|
|
422
|
+
})),
|
|
423
|
+
};
|
|
424
|
+
// Replace the archetype entry with the real one
|
|
425
|
+
const idx = UNIVERSES.findIndex((u) => u.id === slug);
|
|
426
|
+
if (idx >= 0)
|
|
427
|
+
UNIVERSES.splice(idx, 1);
|
|
428
|
+
UNIVERSES.push(universe);
|
|
429
|
+
return universe;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
console.error(`[io] Failed to generate universe roster for "${input}":`, err);
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
try {
|
|
437
|
+
await session?.destroy();
|
|
438
|
+
}
|
|
439
|
+
catch { /* best-effort cleanup */ }
|
|
440
|
+
}
|
|
441
|
+
// LLM failed — return the archetype fallback (already registered)
|
|
442
|
+
return existing;
|
|
443
|
+
}
|
|
231
444
|
/**
|
|
232
445
|
* Get a universe by ID.
|
|
233
446
|
*/
|
package/dist/store/feed.test.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { setDbPathForTests, closeDb, getDb } from "./db.js";
|
|
13
|
-
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, pruneOldFeedEntries, } from "./feed.js";
|
|
13
|
+
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, markFeedEntriesRead, deleteFeedEntry, deleteFeedEntries, pruneOldFeedEntries, } from "./feed.js";
|
|
14
14
|
// ── DB isolation ─────────────────────────────────────────────────────────────
|
|
15
15
|
let tmpDir;
|
|
16
16
|
before(() => {
|
|
@@ -166,6 +166,45 @@ describe("markAllFeedEntriesRead", () => {
|
|
|
166
166
|
assert.equal(countUnreadFeedEntries("notification"), 0);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
|
+
// ── markFeedEntriesRead (batch) ───────────────────────────────────────────────
|
|
170
|
+
describe("markFeedEntriesRead", () => {
|
|
171
|
+
it("marks multiple entries read and returns change count", () => {
|
|
172
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
173
|
+
const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
|
|
174
|
+
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
175
|
+
const count = markFeedEntriesRead([a.id, b.id, c.id]);
|
|
176
|
+
assert.equal(count, 3);
|
|
177
|
+
assert.equal(countUnreadFeedEntries(), 0);
|
|
178
|
+
});
|
|
179
|
+
it("returns 0 for an empty array without throwing", () => {
|
|
180
|
+
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
181
|
+
assert.equal(markFeedEntriesRead([]), 0);
|
|
182
|
+
assert.equal(countUnreadFeedEntries(), 1);
|
|
183
|
+
});
|
|
184
|
+
it("works correctly for a single id", () => {
|
|
185
|
+
const e = createFeedEntry({ type: "deliverable", title: "Solo", body: "b" });
|
|
186
|
+
assert.equal(markFeedEntriesRead([e.id]), 1);
|
|
187
|
+
const entries = listFeedEntries();
|
|
188
|
+
assert.ok(entries[0].read_at !== null);
|
|
189
|
+
});
|
|
190
|
+
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
191
|
+
assert.equal(markFeedEntriesRead([9991, 9992, 9993]), 0);
|
|
192
|
+
});
|
|
193
|
+
it("is idempotent — already-read entries count as 0 changes", () => {
|
|
194
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
195
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
196
|
+
markFeedEntriesRead([a.id, b.id]);
|
|
197
|
+
assert.equal(markFeedEntriesRead([a.id, b.id]), 0);
|
|
198
|
+
});
|
|
199
|
+
it("skips already-read entries and marks only unread ones", () => {
|
|
200
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
201
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
202
|
+
markFeedEntriesRead([a.id]);
|
|
203
|
+
const count = markFeedEntriesRead([a.id, b.id]);
|
|
204
|
+
assert.equal(count, 1);
|
|
205
|
+
assert.equal(countUnreadFeedEntries(), 0);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
169
208
|
// ── deleteFeedEntry ───────────────────────────────────────────────────────────
|
|
170
209
|
describe("deleteFeedEntry", () => {
|
|
171
210
|
it("returns false for a non-existent id", () => {
|
|
@@ -183,6 +222,39 @@ describe("deleteFeedEntry", () => {
|
|
|
183
222
|
assert.equal(deleteFeedEntry(e.id), false);
|
|
184
223
|
});
|
|
185
224
|
});
|
|
225
|
+
// ── deleteFeedEntries (batch) ─────────────────────────────────────────────────
|
|
226
|
+
describe("deleteFeedEntries", () => {
|
|
227
|
+
it("deletes multiple entries and returns change count", () => {
|
|
228
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
229
|
+
const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
|
|
230
|
+
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
231
|
+
const count = deleteFeedEntries([a.id, b.id, c.id]);
|
|
232
|
+
assert.equal(count, 3);
|
|
233
|
+
assert.deepEqual(listFeedEntries(), []);
|
|
234
|
+
});
|
|
235
|
+
it("returns 0 for an empty array without throwing", () => {
|
|
236
|
+
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
237
|
+
assert.equal(deleteFeedEntries([]), 0);
|
|
238
|
+
assert.equal(listFeedEntries().length, 1);
|
|
239
|
+
});
|
|
240
|
+
it("works correctly for a single id", () => {
|
|
241
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
242
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
243
|
+
assert.equal(deleteFeedEntries([a.id]), 1);
|
|
244
|
+
const remaining = listFeedEntries();
|
|
245
|
+
assert.equal(remaining.length, 1);
|
|
246
|
+
assert.equal(remaining[0].id, b.id);
|
|
247
|
+
});
|
|
248
|
+
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
249
|
+
assert.equal(deleteFeedEntries([9991, 9992, 9993]), 0);
|
|
250
|
+
});
|
|
251
|
+
it("mix of existing and non-existent ids — only deletes what exists", () => {
|
|
252
|
+
const e = createFeedEntry({ type: "deliverable", title: "Real", body: "b" });
|
|
253
|
+
const count = deleteFeedEntries([e.id, 9999]);
|
|
254
|
+
assert.equal(count, 1);
|
|
255
|
+
assert.deepEqual(listFeedEntries(), []);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
186
258
|
// ── pruneOldFeedEntries ───────────────────────────────────────────────────────
|
|
187
259
|
describe("pruneOldFeedEntries", () => {
|
|
188
260
|
it("returns 0 when nothing is old enough to prune", () => {
|
package/dist/store/squads.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
|
-
import { nextCharacter, randomUniverse,
|
|
2
|
+
import { nextCharacter, randomUniverse, getOrCreateUniverse } from "../copilot/universes.js";
|
|
3
3
|
export function createSquad(slug, name, projectPath, universeId) {
|
|
4
4
|
const db = getDb();
|
|
5
5
|
const universe = universeId
|
|
6
|
-
?
|
|
6
|
+
? getOrCreateUniverse(universeId).id
|
|
7
7
|
: randomUniverse().id;
|
|
8
8
|
db.prepare("INSERT INTO squads (slug, name, project_path, universe) VALUES (?, ?, ?, ?)").run(slug, name, projectPath, universe);
|
|
9
9
|
return getSquad(slug);
|