heyio 0.5.0 → 0.8.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/api/server.js +121 -2
- package/dist/config.js +3 -0
- package/dist/copilot/agents.js +172 -12
- package/dist/copilot/io-scheduler.js +26 -3
- package/dist/copilot/orchestrator.js +30 -3
- package/dist/copilot/scheduler.js +24 -2
- package/dist/copilot/session-timeout.js +112 -0
- package/dist/copilot/session-timeout.test.js +372 -0
- package/dist/copilot/skills.js +78 -4
- package/dist/copilot/system-message.js +12 -8
- package/dist/copilot/tools.js +316 -20
- package/dist/daemon.js +35 -2
- package/dist/notify.js +105 -0
- package/dist/notify.test.js +232 -0
- package/dist/store/db.js +47 -2
- package/dist/store/notifications.js +79 -0
- package/dist/store/notifications.test.js +197 -0
- package/dist/store/schedule-runs.js +46 -0
- package/dist/store/squads.js +10 -0
- package/dist/store/tasks.js +122 -0
- package/dist/telegram/bot.js +14 -0
- package/dist/tui/index.js +73 -0
- package/package.json +3 -2
- package/web-dist/assets/index-CUwy4ylb.js +74 -0
- package/web-dist/assets/index-oSVFpNBp.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BYoiwmlj.js +0 -74
- package/web-dist/assets/index-DMKRXYjX.css +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/notify.ts — isMeaningfulOutput heuristic and notifyBackground
|
|
3
|
+
* dispatch routing.
|
|
4
|
+
*
|
|
5
|
+
* DB isolation: setDbPathForTests() redirects the SQLite singleton to a
|
|
6
|
+
* fresh tmp file so these tests never touch ~/.io/io.db.
|
|
7
|
+
*/
|
|
8
|
+
import { before, after, afterEach, describe, it } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { setDbPathForTests, closeDb } from "./store/db.js";
|
|
14
|
+
import { config } from "./config.js";
|
|
15
|
+
import { isMeaningfulOutput, notifyBackground, setTelegramSender, setTuiSender, setSseBroadcaster, _resetNotifySendersForTests, } from "./notify.js";
|
|
16
|
+
// ── DB isolation ────────────────────────────────────────────────────────────
|
|
17
|
+
let tmpDir;
|
|
18
|
+
before(() => {
|
|
19
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-notify-test-"));
|
|
20
|
+
setDbPathForTests(join(tmpDir, "io.db"));
|
|
21
|
+
});
|
|
22
|
+
after(() => {
|
|
23
|
+
closeDb();
|
|
24
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
// ── Config teardown ─────────────────────────────────────────────────────────
|
|
27
|
+
const origMode = config.backgroundNotifyMode;
|
|
28
|
+
const origTelegram = config.backgroundNotifyTelegram;
|
|
29
|
+
const origTui = config.backgroundNotifyTui;
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
config.backgroundNotifyMode = origMode;
|
|
32
|
+
config.backgroundNotifyTelegram = origTelegram;
|
|
33
|
+
config.backgroundNotifyTui = origTui;
|
|
34
|
+
_resetNotifySendersForTests();
|
|
35
|
+
});
|
|
36
|
+
// ── isMeaningfulOutput ───────────────────────────────────────────────────────
|
|
37
|
+
describe("isMeaningfulOutput", () => {
|
|
38
|
+
describe("returns false for short/empty input", () => {
|
|
39
|
+
it("empty string", () => assert.equal(isMeaningfulOutput(""), false));
|
|
40
|
+
it("whitespace only", () => assert.equal(isMeaningfulOutput(" "), false));
|
|
41
|
+
it("under 20 chars", () => assert.equal(isMeaningfulOutput("short"), false));
|
|
42
|
+
it("exactly 19 chars", () => assert.equal(isMeaningfulOutput("a".repeat(19)), false));
|
|
43
|
+
});
|
|
44
|
+
describe("returns false for heartbeat phrases", () => {
|
|
45
|
+
const phrases = [
|
|
46
|
+
"no active tasks",
|
|
47
|
+
"no active task",
|
|
48
|
+
"nothing to report.",
|
|
49
|
+
"Nothing to report.",
|
|
50
|
+
"ALL CLEAR",
|
|
51
|
+
"no updates",
|
|
52
|
+
"no update",
|
|
53
|
+
"no changes",
|
|
54
|
+
"no change",
|
|
55
|
+
"idle",
|
|
56
|
+
"IDLE",
|
|
57
|
+
"heartbeat",
|
|
58
|
+
"ok",
|
|
59
|
+
"OK",
|
|
60
|
+
];
|
|
61
|
+
for (const phrase of phrases) {
|
|
62
|
+
it(`"${phrase}"`, () => assert.equal(isMeaningfulOutput(phrase), false));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it("returns false when heartbeat phrase is first non-empty line (blank preamble)", () => {
|
|
66
|
+
const text = "\n\n \nno active tasks\nsome more content here that is long";
|
|
67
|
+
assert.equal(isMeaningfulOutput(text), false);
|
|
68
|
+
});
|
|
69
|
+
it("returns false when heartbeat is surrounded by whitespace on its line", () => {
|
|
70
|
+
assert.equal(isMeaningfulOutput(" idle "), false);
|
|
71
|
+
});
|
|
72
|
+
it("returns true for normal multi-sentence prose ≥20 chars", () => {
|
|
73
|
+
assert.equal(isMeaningfulOutput("Task 1 completed successfully. Moving to step 2."), true);
|
|
74
|
+
});
|
|
75
|
+
it("returns true for a bulleted report whose first line is not a heartbeat", () => {
|
|
76
|
+
const text = [
|
|
77
|
+
"Squad status update:",
|
|
78
|
+
"- Lion-O: working on PR #80",
|
|
79
|
+
"- Tygra: reviewing PR #79",
|
|
80
|
+
"- Cheetara: idle",
|
|
81
|
+
].join("\n");
|
|
82
|
+
assert.equal(isMeaningfulOutput(text), true);
|
|
83
|
+
});
|
|
84
|
+
it("returns true for exactly 20 chars", () => {
|
|
85
|
+
assert.equal(isMeaningfulOutput("a".repeat(20)), true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// ── notifyBackground dispatch routing ───────────────────────────────────────
|
|
89
|
+
const MEANINGFUL_TEXT = "Squad task monitor: PR #80 merged. Lion-O completed the delegation stats feature. All checks passing.";
|
|
90
|
+
const HEARTBEAT_TEXT = "no active tasks";
|
|
91
|
+
const SOURCE = {
|
|
92
|
+
type: "squad-schedule",
|
|
93
|
+
scheduleId: 7,
|
|
94
|
+
squadSlug: "michaeljolley-io",
|
|
95
|
+
scheduleName: "Active Task Monitor",
|
|
96
|
+
};
|
|
97
|
+
describe("notifyBackground", () => {
|
|
98
|
+
it('mode="off" → skipped:"off", no senders called, no row inserted', async () => {
|
|
99
|
+
config.backgroundNotifyMode = "off";
|
|
100
|
+
let called = false;
|
|
101
|
+
setSseBroadcaster(() => { called = true; });
|
|
102
|
+
setTuiSender(() => { called = true; });
|
|
103
|
+
setTelegramSender(async () => { called = true; });
|
|
104
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
105
|
+
assert.equal(result.skipped, "off");
|
|
106
|
+
assert.equal(result.id, undefined);
|
|
107
|
+
assert.equal(called, false);
|
|
108
|
+
});
|
|
109
|
+
it("empty text → skipped:empty, no row inserted", async () => {
|
|
110
|
+
config.backgroundNotifyMode = "all";
|
|
111
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: " " });
|
|
112
|
+
assert.equal(result.skipped, "empty");
|
|
113
|
+
assert.equal(result.id, undefined);
|
|
114
|
+
});
|
|
115
|
+
it('mode="meaningful" + heartbeat text → skipped:"not-meaningful", no row', async () => {
|
|
116
|
+
config.backgroundNotifyMode = "meaningful";
|
|
117
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
|
|
118
|
+
assert.equal(result.skipped, "not-meaningful");
|
|
119
|
+
assert.equal(result.id, undefined);
|
|
120
|
+
});
|
|
121
|
+
it('mode="all" + heartbeat + all senders → all dispatch, row inserted', async () => {
|
|
122
|
+
config.backgroundNotifyMode = "all";
|
|
123
|
+
config.backgroundNotifyTelegram = true;
|
|
124
|
+
config.backgroundNotifyTui = true;
|
|
125
|
+
const calls = { sse: false, tui: false, telegram: false };
|
|
126
|
+
setSseBroadcaster(() => { calls.sse = true; });
|
|
127
|
+
setTuiSender(() => { calls.tui = true; });
|
|
128
|
+
setTelegramSender(async () => { calls.telegram = true; });
|
|
129
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
|
|
130
|
+
assert.ok(result.id, "should return a row id");
|
|
131
|
+
assert.equal(result.dispatched.sse, true);
|
|
132
|
+
assert.equal(result.dispatched.tui, true);
|
|
133
|
+
assert.equal(result.dispatched.telegram, true);
|
|
134
|
+
assert.equal(calls.sse, true);
|
|
135
|
+
assert.equal(calls.tui, true);
|
|
136
|
+
assert.equal(calls.telegram, true);
|
|
137
|
+
assert.equal(result.skipped, undefined);
|
|
138
|
+
});
|
|
139
|
+
it('mode="meaningful" + meaningful text + all senders → all dispatch', async () => {
|
|
140
|
+
config.backgroundNotifyMode = "meaningful";
|
|
141
|
+
config.backgroundNotifyTelegram = true;
|
|
142
|
+
config.backgroundNotifyTui = true;
|
|
143
|
+
const calls = { sse: false, tui: false, telegram: false };
|
|
144
|
+
setSseBroadcaster(() => { calls.sse = true; });
|
|
145
|
+
setTuiSender(() => { calls.tui = true; });
|
|
146
|
+
setTelegramSender(async () => { calls.telegram = true; });
|
|
147
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
148
|
+
assert.ok(result.id);
|
|
149
|
+
assert.equal(result.dispatched.sse, true);
|
|
150
|
+
assert.equal(result.dispatched.tui, true);
|
|
151
|
+
assert.equal(result.dispatched.telegram, true);
|
|
152
|
+
});
|
|
153
|
+
it("backgroundNotifyTelegram=false → telegram skipped, tui+sse still fire", async () => {
|
|
154
|
+
config.backgroundNotifyMode = "all";
|
|
155
|
+
config.backgroundNotifyTelegram = false;
|
|
156
|
+
config.backgroundNotifyTui = true;
|
|
157
|
+
const calls = { sse: false, tui: false, telegram: false };
|
|
158
|
+
setSseBroadcaster(() => { calls.sse = true; });
|
|
159
|
+
setTuiSender(() => { calls.tui = true; });
|
|
160
|
+
setTelegramSender(async () => { calls.telegram = true; });
|
|
161
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
162
|
+
assert.equal(result.dispatched.telegram, false, "telegram should be skipped");
|
|
163
|
+
assert.equal(result.dispatched.tui, true);
|
|
164
|
+
assert.equal(result.dispatched.sse, true);
|
|
165
|
+
assert.equal(calls.telegram, false);
|
|
166
|
+
});
|
|
167
|
+
it("backgroundNotifyTui=false → tui skipped, telegram+sse still fire", async () => {
|
|
168
|
+
config.backgroundNotifyMode = "all";
|
|
169
|
+
config.backgroundNotifyTelegram = true;
|
|
170
|
+
config.backgroundNotifyTui = false;
|
|
171
|
+
const calls = { sse: false, tui: false, telegram: false };
|
|
172
|
+
setSseBroadcaster(() => { calls.sse = true; });
|
|
173
|
+
setTuiSender(() => { calls.tui = true; });
|
|
174
|
+
setTelegramSender(async () => { calls.telegram = true; });
|
|
175
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
176
|
+
assert.equal(result.dispatched.tui, false, "tui should be skipped");
|
|
177
|
+
assert.equal(result.dispatched.telegram, true);
|
|
178
|
+
assert.equal(result.dispatched.sse, true);
|
|
179
|
+
assert.equal(calls.tui, false);
|
|
180
|
+
});
|
|
181
|
+
it("telegram sender throws → dispatched.telegram=false, others true, no rethrow", async () => {
|
|
182
|
+
config.backgroundNotifyMode = "all";
|
|
183
|
+
config.backgroundNotifyTelegram = true;
|
|
184
|
+
config.backgroundNotifyTui = true;
|
|
185
|
+
const calls = { sse: false, tui: false };
|
|
186
|
+
setSseBroadcaster(() => { calls.sse = true; });
|
|
187
|
+
setTuiSender(() => { calls.tui = true; });
|
|
188
|
+
setTelegramSender(async () => { throw new Error("telegram down"); });
|
|
189
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
190
|
+
assert.equal(result.dispatched.telegram, false);
|
|
191
|
+
assert.equal(result.dispatched.sse, true);
|
|
192
|
+
assert.equal(result.dispatched.tui, true);
|
|
193
|
+
assert.ok(result.id, "row should still be inserted");
|
|
194
|
+
});
|
|
195
|
+
it("TUI sender throws → dispatched.tui=false, others true, no rethrow", async () => {
|
|
196
|
+
config.backgroundNotifyMode = "all";
|
|
197
|
+
config.backgroundNotifyTelegram = true;
|
|
198
|
+
config.backgroundNotifyTui = true;
|
|
199
|
+
setSseBroadcaster(() => { });
|
|
200
|
+
setTuiSender(() => { throw new Error("tui down"); });
|
|
201
|
+
setTelegramSender(async () => { });
|
|
202
|
+
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
203
|
+
assert.equal(result.dispatched.tui, false);
|
|
204
|
+
assert.equal(result.dispatched.sse, true);
|
|
205
|
+
assert.equal(result.dispatched.telegram, true);
|
|
206
|
+
});
|
|
207
|
+
it("squad-schedule source detail round-trips to SSE broadcaster payload", async () => {
|
|
208
|
+
config.backgroundNotifyMode = "all";
|
|
209
|
+
let capturedPayload;
|
|
210
|
+
setSseBroadcaster((payload) => { capturedPayload = payload; });
|
|
211
|
+
const source = {
|
|
212
|
+
type: "squad-schedule",
|
|
213
|
+
scheduleId: 42,
|
|
214
|
+
squadSlug: "thundercats",
|
|
215
|
+
scheduleName: "Morning Standup",
|
|
216
|
+
};
|
|
217
|
+
const result = await notifyBackground({
|
|
218
|
+
source,
|
|
219
|
+
title: "Morning Standup Result",
|
|
220
|
+
text: MEANINGFUL_TEXT,
|
|
221
|
+
});
|
|
222
|
+
assert.ok(capturedPayload, "SSE broadcaster should have been called");
|
|
223
|
+
assert.equal(capturedPayload.id, result.id);
|
|
224
|
+
assert.equal(capturedPayload.source.type, "squad-schedule");
|
|
225
|
+
assert.equal(capturedPayload.source.scheduleId, 42);
|
|
226
|
+
assert.equal(capturedPayload.source.squadSlug, "thundercats");
|
|
227
|
+
assert.equal(capturedPayload.source.scheduleName, "Morning Standup");
|
|
228
|
+
assert.equal(capturedPayload.title, "Morning Standup Result");
|
|
229
|
+
assert.ok(capturedPayload.createdAt, "createdAt should be set");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
//# sourceMappingURL=notify.test.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { mkdirSync } from "fs";
|
|
3
|
+
import { dirname } from "path";
|
|
3
4
|
import { DB_PATH, IO_HOME } from "../paths.js";
|
|
4
5
|
let db = null;
|
|
5
6
|
let insertCount = 0;
|
|
7
|
+
let dbPathOverride = null;
|
|
8
|
+
/**
|
|
9
|
+
* Override the DB path for tests. Closes the existing connection (if any)
|
|
10
|
+
* so the next getDb() call opens a fresh DB at the given path.
|
|
11
|
+
* Never call this in production code.
|
|
12
|
+
*/
|
|
13
|
+
export function setDbPathForTests(path) {
|
|
14
|
+
if (db) {
|
|
15
|
+
db.close();
|
|
16
|
+
db = null;
|
|
17
|
+
}
|
|
18
|
+
dbPathOverride = path;
|
|
19
|
+
}
|
|
6
20
|
export function getDb() {
|
|
7
21
|
if (db)
|
|
8
22
|
return db;
|
|
9
|
-
|
|
10
|
-
|
|
23
|
+
const resolvedPath = dbPathOverride ?? DB_PATH;
|
|
24
|
+
const resolvedHome = dbPathOverride ? dirname(resolvedPath) : IO_HOME;
|
|
25
|
+
mkdirSync(resolvedHome, { recursive: true });
|
|
26
|
+
db = new Database(resolvedPath);
|
|
11
27
|
db.pragma("journal_mode = WAL");
|
|
12
28
|
db.exec(`
|
|
13
29
|
CREATE TABLE IF NOT EXISTS io_state (
|
|
@@ -109,6 +125,35 @@ export function getDb() {
|
|
|
109
125
|
)`,
|
|
110
126
|
`CREATE INDEX IF NOT EXISTS idx_io_schedules_due
|
|
111
127
|
ON io_schedules (enabled, next_run_at)`,
|
|
128
|
+
`CREATE VIEW IF NOT EXISTS agent_stats AS
|
|
129
|
+
SELECT agent_slug,
|
|
130
|
+
COUNT(*) AS task_count,
|
|
131
|
+
MAX(started_at) AS last_delegated_at
|
|
132
|
+
FROM agent_tasks
|
|
133
|
+
GROUP BY agent_slug`,
|
|
134
|
+
`CREATE TABLE IF NOT EXISTS background_notifications (
|
|
135
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
136
|
+
source_type TEXT NOT NULL,
|
|
137
|
+
source_ref TEXT,
|
|
138
|
+
title TEXT NOT NULL,
|
|
139
|
+
text TEXT NOT NULL,
|
|
140
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
141
|
+
read_at DATETIME
|
|
142
|
+
)`,
|
|
143
|
+
`CREATE INDEX IF NOT EXISTS idx_bg_notifications_unread ON background_notifications(read_at, created_at)`,
|
|
144
|
+
`CREATE TABLE IF NOT EXISTS schedule_runs (
|
|
145
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
146
|
+
schedule_type TEXT NOT NULL,
|
|
147
|
+
schedule_id INTEGER NOT NULL,
|
|
148
|
+
schedule_name TEXT NOT NULL,
|
|
149
|
+
squad_slug TEXT,
|
|
150
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
151
|
+
error_text TEXT,
|
|
152
|
+
notification_id INTEGER,
|
|
153
|
+
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
154
|
+
completed_at DATETIME
|
|
155
|
+
)`,
|
|
156
|
+
`CREATE INDEX IF NOT EXISTS idx_schedule_runs_lookup ON schedule_runs(schedule_type, schedule_id, started_at)`,
|
|
112
157
|
];
|
|
113
158
|
for (const migration of migrations) {
|
|
114
159
|
try {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
/**
|
|
3
|
+
* Insert a new background notification. Returns the inserted row including
|
|
4
|
+
* the autoincrement id and DB-assigned created_at timestamp. source_ref
|
|
5
|
+
* should be a JSON string or null.
|
|
6
|
+
*/
|
|
7
|
+
export function insertNotification(input) {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
const info = db
|
|
10
|
+
.prepare(`INSERT INTO background_notifications (source_type, source_ref, title, text)
|
|
11
|
+
VALUES (?, ?, ?, ?)`)
|
|
12
|
+
.run(input.source_type, input.source_ref, input.title, input.text);
|
|
13
|
+
return db
|
|
14
|
+
.prepare("SELECT * FROM background_notifications WHERE id = ?")
|
|
15
|
+
.get(info.lastInsertRowid);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* List the most recent notifications, newest first. Default limit 50.
|
|
19
|
+
*/
|
|
20
|
+
export function listRecentNotifications(limit = 50) {
|
|
21
|
+
return getDb()
|
|
22
|
+
.prepare("SELECT * FROM background_notifications ORDER BY created_at DESC, id DESC LIMIT ?")
|
|
23
|
+
.all(limit);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* List unread notifications (read_at IS NULL), newest first.
|
|
27
|
+
*/
|
|
28
|
+
export function listUnreadNotifications() {
|
|
29
|
+
return getDb()
|
|
30
|
+
.prepare("SELECT * FROM background_notifications WHERE read_at IS NULL ORDER BY created_at DESC, id DESC")
|
|
31
|
+
.all();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Count unread notifications. Cheap — uses COUNT(*).
|
|
35
|
+
*/
|
|
36
|
+
export function countUnreadNotifications() {
|
|
37
|
+
const row = getDb()
|
|
38
|
+
.prepare("SELECT COUNT(*) AS n FROM background_notifications WHERE read_at IS NULL")
|
|
39
|
+
.get();
|
|
40
|
+
return row.n;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Mark a single notification read. Returns true if the row exists (whether
|
|
44
|
+
* it was already read or just now marked), false if no such id exists.
|
|
45
|
+
*/
|
|
46
|
+
export function markNotificationRead(id) {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
const info = db
|
|
49
|
+
.prepare("UPDATE background_notifications SET read_at = CURRENT_TIMESTAMP WHERE id = ? AND read_at IS NULL")
|
|
50
|
+
.run(id);
|
|
51
|
+
if (info.changes > 0)
|
|
52
|
+
return true;
|
|
53
|
+
// Already read — verify the row exists at all
|
|
54
|
+
const exists = db
|
|
55
|
+
.prepare("SELECT id FROM background_notifications WHERE id = ?")
|
|
56
|
+
.get(id);
|
|
57
|
+
return exists !== undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mark every unread notification read. Returns the number of rows affected.
|
|
61
|
+
*/
|
|
62
|
+
export function markAllNotificationsRead() {
|
|
63
|
+
const info = getDb()
|
|
64
|
+
.prepare("UPDATE background_notifications SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
|
|
65
|
+
.run();
|
|
66
|
+
return info.changes;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Delete notifications older than `olderThanDays` days. Returns rows deleted.
|
|
70
|
+
* Used by a future retention sweep.
|
|
71
|
+
*/
|
|
72
|
+
export function pruneOldNotifications(olderThanDays) {
|
|
73
|
+
const info = getDb()
|
|
74
|
+
.prepare(`DELETE FROM background_notifications
|
|
75
|
+
WHERE created_at < datetime('now', ? || ' days')`)
|
|
76
|
+
.run(`-${olderThanDays}`);
|
|
77
|
+
return info.changes;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=notifications.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/store/notifications.ts — SQLite CRUD helpers.
|
|
3
|
+
*
|
|
4
|
+
* DB isolation: setDbPathForTests() redirects the SQLite singleton to a
|
|
5
|
+
* fresh tmp file, ensuring these tests never touch ~/.io/io.db.
|
|
6
|
+
*/
|
|
7
|
+
import { before, after, beforeEach, describe, it } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { setDbPathForTests, closeDb, getDb } from "./db.js";
|
|
13
|
+
import { insertNotification, listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, pruneOldNotifications, } from "./notifications.js";
|
|
14
|
+
// ── DB isolation ────────────────────────────────────────────────────────────
|
|
15
|
+
let tmpDir;
|
|
16
|
+
before(() => {
|
|
17
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-notifs-test-"));
|
|
18
|
+
setDbPathForTests(join(tmpDir, "io.db"));
|
|
19
|
+
});
|
|
20
|
+
// Wipe all notifications between tests for a clean slate.
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
getDb().prepare("DELETE FROM background_notifications").run();
|
|
23
|
+
});
|
|
24
|
+
after(() => {
|
|
25
|
+
closeDb();
|
|
26
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
29
|
+
function makeNotif(overrides = {}) {
|
|
30
|
+
return insertNotification({
|
|
31
|
+
source_type: "io-schedule",
|
|
32
|
+
source_ref: JSON.stringify({ scheduleId: 1 }),
|
|
33
|
+
title: "Test Notification",
|
|
34
|
+
text: "This is the notification body.",
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// ── insertNotification ───────────────────────────────────────────────────────
|
|
39
|
+
describe("insertNotification", () => {
|
|
40
|
+
it("returns a row with autoincrement id and created_at timestamp", () => {
|
|
41
|
+
const row = makeNotif();
|
|
42
|
+
assert.ok(typeof row.id === "number" && row.id > 0, "id should be a positive integer");
|
|
43
|
+
assert.ok(row.created_at, "created_at should be set");
|
|
44
|
+
assert.equal(row.title, "Test Notification");
|
|
45
|
+
assert.equal(row.text, "This is the notification body.");
|
|
46
|
+
assert.equal(row.read_at, null);
|
|
47
|
+
});
|
|
48
|
+
it("ids are autoincremented across inserts", () => {
|
|
49
|
+
const a = makeNotif();
|
|
50
|
+
const b = makeNotif();
|
|
51
|
+
assert.ok(b.id > a.id, "second id should be greater than first");
|
|
52
|
+
});
|
|
53
|
+
it("accepts source_ref: null", () => {
|
|
54
|
+
const row = makeNotif({ source_ref: null });
|
|
55
|
+
assert.equal(row.source_ref, null);
|
|
56
|
+
});
|
|
57
|
+
it("stores source_type correctly", () => {
|
|
58
|
+
const row = makeNotif({ source_type: "squad-schedule" });
|
|
59
|
+
assert.equal(row.source_type, "squad-schedule");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// ── listRecentNotifications ──────────────────────────────────────────────────
|
|
63
|
+
describe("listRecentNotifications", () => {
|
|
64
|
+
it("returns newest first", () => {
|
|
65
|
+
const a = makeNotif({ title: "First" });
|
|
66
|
+
const b = makeNotif({ title: "Second" });
|
|
67
|
+
const c = makeNotif({ title: "Third" });
|
|
68
|
+
const rows = listRecentNotifications();
|
|
69
|
+
assert.equal(rows[0].id, c.id, "newest should be first");
|
|
70
|
+
assert.equal(rows[rows.length - 1].id, a.id, "oldest should be last");
|
|
71
|
+
});
|
|
72
|
+
it("default limit is 50", () => {
|
|
73
|
+
for (let i = 0; i < 55; i++)
|
|
74
|
+
makeNotif({ title: `N${i}` });
|
|
75
|
+
const rows = listRecentNotifications();
|
|
76
|
+
assert.equal(rows.length, 50);
|
|
77
|
+
});
|
|
78
|
+
it("explicit limit is honored", () => {
|
|
79
|
+
for (let i = 0; i < 10; i++)
|
|
80
|
+
makeNotif({ title: `N${i}` });
|
|
81
|
+
const rows = listRecentNotifications(3);
|
|
82
|
+
assert.equal(rows.length, 3);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// ── countUnreadNotifications ─────────────────────────────────────────────────
|
|
86
|
+
describe("countUnreadNotifications", () => {
|
|
87
|
+
it("starts at zero on a clean DB", () => {
|
|
88
|
+
assert.equal(countUnreadNotifications(), 0);
|
|
89
|
+
});
|
|
90
|
+
it("increases on insert", () => {
|
|
91
|
+
makeNotif();
|
|
92
|
+
assert.equal(countUnreadNotifications(), 1);
|
|
93
|
+
makeNotif();
|
|
94
|
+
assert.equal(countUnreadNotifications(), 2);
|
|
95
|
+
});
|
|
96
|
+
it("decreases when a notification is marked read", () => {
|
|
97
|
+
const a = makeNotif();
|
|
98
|
+
makeNotif();
|
|
99
|
+
assert.equal(countUnreadNotifications(), 2);
|
|
100
|
+
markNotificationRead(a.id);
|
|
101
|
+
assert.equal(countUnreadNotifications(), 1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ── markNotificationRead ─────────────────────────────────────────────────────
|
|
105
|
+
describe("markNotificationRead", () => {
|
|
106
|
+
it("returns false on a non-existent id", () => {
|
|
107
|
+
assert.equal(markNotificationRead(999999), false);
|
|
108
|
+
});
|
|
109
|
+
it("returns true on an existing unread notification", () => {
|
|
110
|
+
const row = makeNotif();
|
|
111
|
+
assert.equal(markNotificationRead(row.id), true);
|
|
112
|
+
});
|
|
113
|
+
it("is idempotent — returns true even if already read", () => {
|
|
114
|
+
const row = makeNotif();
|
|
115
|
+
assert.equal(markNotificationRead(row.id), true);
|
|
116
|
+
assert.equal(markNotificationRead(row.id), true, "second call should still return true");
|
|
117
|
+
});
|
|
118
|
+
it("sets read_at on the row", () => {
|
|
119
|
+
const row = makeNotif();
|
|
120
|
+
markNotificationRead(row.id);
|
|
121
|
+
const updated = getDb()
|
|
122
|
+
.prepare("SELECT read_at FROM background_notifications WHERE id = ?")
|
|
123
|
+
.get(row.id);
|
|
124
|
+
assert.ok(updated.read_at, "read_at should be set after marking read");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
// ── markAllNotificationsRead ─────────────────────────────────────────────────
|
|
128
|
+
describe("markAllNotificationsRead", () => {
|
|
129
|
+
it("returns the count of newly-marked rows", () => {
|
|
130
|
+
makeNotif();
|
|
131
|
+
makeNotif();
|
|
132
|
+
makeNotif();
|
|
133
|
+
assert.equal(markAllNotificationsRead(), 3);
|
|
134
|
+
});
|
|
135
|
+
it("subsequent call returns 0 (all already read)", () => {
|
|
136
|
+
makeNotif();
|
|
137
|
+
makeNotif();
|
|
138
|
+
markAllNotificationsRead();
|
|
139
|
+
assert.equal(markAllNotificationsRead(), 0);
|
|
140
|
+
});
|
|
141
|
+
it("only marks unread rows — pre-read rows not re-touched", () => {
|
|
142
|
+
const a = makeNotif();
|
|
143
|
+
makeNotif();
|
|
144
|
+
markNotificationRead(a.id); // mark one manually first
|
|
145
|
+
const count = markAllNotificationsRead(); // should only mark the remaining 1
|
|
146
|
+
assert.equal(count, 1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ── listUnreadNotifications ──────────────────────────────────────────────────
|
|
150
|
+
describe("listUnreadNotifications", () => {
|
|
151
|
+
it("excludes read rows", () => {
|
|
152
|
+
const a = makeNotif({ title: "A" });
|
|
153
|
+
const b = makeNotif({ title: "B" });
|
|
154
|
+
markNotificationRead(a.id);
|
|
155
|
+
const unread = listUnreadNotifications();
|
|
156
|
+
assert.ok(!unread.some((r) => r.id === a.id), "read row should not appear");
|
|
157
|
+
assert.ok(unread.some((r) => r.id === b.id), "unread row should appear");
|
|
158
|
+
});
|
|
159
|
+
it("returns newest first", () => {
|
|
160
|
+
const a = makeNotif({ title: "A" });
|
|
161
|
+
const b = makeNotif({ title: "B" });
|
|
162
|
+
const rows = listUnreadNotifications();
|
|
163
|
+
assert.equal(rows[0].id, b.id);
|
|
164
|
+
assert.equal(rows[1].id, a.id);
|
|
165
|
+
});
|
|
166
|
+
it("returns empty array when all are read", () => {
|
|
167
|
+
makeNotif();
|
|
168
|
+
makeNotif();
|
|
169
|
+
markAllNotificationsRead();
|
|
170
|
+
assert.deepEqual(listUnreadNotifications(), []);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
// ── pruneOldNotifications ─────────────────────────────────────────────────────
|
|
174
|
+
describe("pruneOldNotifications", () => {
|
|
175
|
+
it("deletes rows older than the threshold and returns the count", () => {
|
|
176
|
+
const old = makeNotif({ title: "Old" });
|
|
177
|
+
makeNotif({ title: "Recent" }); // stays untouched
|
|
178
|
+
// Back-date the 'old' row to 10 days ago
|
|
179
|
+
getDb()
|
|
180
|
+
.prepare("UPDATE background_notifications SET created_at = datetime('now', '-10 days') WHERE id = ?")
|
|
181
|
+
.run(old.id);
|
|
182
|
+
const deleted = pruneOldNotifications(7); // prune rows older than 7 days
|
|
183
|
+
assert.equal(deleted, 1, "should delete exactly the back-dated row");
|
|
184
|
+
const remaining = listRecentNotifications();
|
|
185
|
+
assert.ok(!remaining.some((r) => r.id === old.id), "old row should be gone");
|
|
186
|
+
assert.ok(remaining.some((r) => r.title === "Recent"), "recent row should remain");
|
|
187
|
+
});
|
|
188
|
+
it("returns 0 when nothing is old enough to prune", () => {
|
|
189
|
+
makeNotif();
|
|
190
|
+
makeNotif();
|
|
191
|
+
assert.equal(pruneOldNotifications(7), 0);
|
|
192
|
+
});
|
|
193
|
+
it("is safe on an empty table", () => {
|
|
194
|
+
assert.equal(pruneOldNotifications(1), 0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
//# sourceMappingURL=notifications.test.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
/** Create a new run in 'running' status. Returns the row. */
|
|
3
|
+
export function startScheduleRun(input) {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
const info = db
|
|
6
|
+
.prepare(`INSERT INTO schedule_runs (schedule_type, schedule_id, schedule_name, squad_slug)
|
|
7
|
+
VALUES (?, ?, ?, ?)`)
|
|
8
|
+
.run(input.schedule_type, input.schedule_id, input.schedule_name, input.squad_slug ?? null);
|
|
9
|
+
return db
|
|
10
|
+
.prepare("SELECT * FROM schedule_runs WHERE id = ?")
|
|
11
|
+
.get(info.lastInsertRowid);
|
|
12
|
+
}
|
|
13
|
+
/** Mark a run complete (success). Optionally link a notification_id. */
|
|
14
|
+
export function completeScheduleRun(id, notificationId) {
|
|
15
|
+
getDb()
|
|
16
|
+
.prepare(`UPDATE schedule_runs
|
|
17
|
+
SET status = 'done', completed_at = CURRENT_TIMESTAMP, notification_id = ?
|
|
18
|
+
WHERE id = ?`)
|
|
19
|
+
.run(notificationId ?? null, id);
|
|
20
|
+
}
|
|
21
|
+
/** Mark a run failed with an error message. */
|
|
22
|
+
export function failScheduleRun(id, errorText) {
|
|
23
|
+
getDb()
|
|
24
|
+
.prepare(`UPDATE schedule_runs
|
|
25
|
+
SET status = 'failed', completed_at = CURRENT_TIMESTAMP, error_text = ?
|
|
26
|
+
WHERE id = ?`)
|
|
27
|
+
.run(errorText, id);
|
|
28
|
+
}
|
|
29
|
+
/** Get last N runs for a specific schedule. Newest first. */
|
|
30
|
+
export function getScheduleRuns(scheduleType, scheduleId, limit = 10) {
|
|
31
|
+
return getDb()
|
|
32
|
+
.prepare(`SELECT * FROM schedule_runs
|
|
33
|
+
WHERE schedule_type = ? AND schedule_id = ?
|
|
34
|
+
ORDER BY started_at DESC
|
|
35
|
+
LIMIT ?`)
|
|
36
|
+
.all(scheduleType, scheduleId, limit);
|
|
37
|
+
}
|
|
38
|
+
/** Prune runs older than the given number of days. Returns rows deleted. */
|
|
39
|
+
export function pruneOldScheduleRuns(olderThanDays) {
|
|
40
|
+
const info = getDb()
|
|
41
|
+
.prepare(`DELETE FROM schedule_runs
|
|
42
|
+
WHERE started_at < datetime('now', '-' || ? || ' days')`)
|
|
43
|
+
.run(olderThanDays);
|
|
44
|
+
return info.changes;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=schedule-runs.js.map
|
package/dist/store/squads.js
CHANGED
|
@@ -93,6 +93,16 @@ export function updateAgentStatus(squadSlug, characterName, status) {
|
|
|
93
93
|
.prepare("UPDATE squad_agents SET status = ? WHERE squad_slug = ? AND character_name = ?")
|
|
94
94
|
.run(status, squadSlug, characterName);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Clear an agent's persisted copilot_session_id. Used during error recovery
|
|
98
|
+
* so the next task creates a fresh session instead of trying to resume a
|
|
99
|
+
* poisoned one.
|
|
100
|
+
*/
|
|
101
|
+
export function clearAgentSession(squadSlug, characterName) {
|
|
102
|
+
getDb()
|
|
103
|
+
.prepare("UPDATE squad_agents SET copilot_session_id = NULL WHERE squad_slug = ? AND character_name = ?")
|
|
104
|
+
.run(squadSlug, characterName);
|
|
105
|
+
}
|
|
96
106
|
/**
|
|
97
107
|
* Reset any agent left in a non-idle status from a previous daemon run.
|
|
98
108
|
* The in-memory Copilot sessions don't survive a restart, so persisted
|