heyio 0.6.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 +90 -1
- package/dist/config.js +3 -0
- package/dist/copilot/io-scheduler.js +26 -3
- package/dist/copilot/scheduler.js +24 -2
- package/dist/copilot/session-timeout.test.js +372 -0
- package/dist/copilot/skills.js +78 -4
- package/dist/copilot/tools.js +2 -2
- package/dist/daemon.js +35 -2
- package/dist/notify.js +105 -0
- package/dist/notify.test.js +232 -0
- package/dist/store/db.js +41 -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/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-BlZDeDCS.js +0 -74
- package/web-dist/assets/index-DMKRXYjX.css +0 -1
package/dist/copilot/skills.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
2
2
|
import { join, basename } from "path";
|
|
3
3
|
import { execSync } from "child_process";
|
|
4
4
|
import { SKILLS_DIR } from "../paths.js";
|
|
@@ -80,11 +80,85 @@ export function listSkills() {
|
|
|
80
80
|
}
|
|
81
81
|
return skills;
|
|
82
82
|
}
|
|
83
|
+
const GITHUB_BLOB_RE = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
|
|
84
|
+
const RAW_GH_RE = /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
|
|
85
|
+
const GENERIC_SKILL_MD_RE = /\/SKILL\.md$/i;
|
|
86
|
+
function deriveSlug(repo, pathPrefix) {
|
|
87
|
+
if (!pathPrefix)
|
|
88
|
+
return repo;
|
|
89
|
+
const segments = pathPrefix.replace(/\/$/, "").split("/").filter(Boolean);
|
|
90
|
+
const last = segments[segments.length - 1];
|
|
91
|
+
return last ? `${repo}-${last}` : repo;
|
|
92
|
+
}
|
|
83
93
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
94
|
+
* Determine whether the input URL points to a full repo or a specific
|
|
95
|
+
* SKILL.md file. For GitHub blob URLs the raw download URL is derived
|
|
96
|
+
* automatically.
|
|
86
97
|
*/
|
|
87
|
-
export
|
|
98
|
+
export function parseSkillUrl(input) {
|
|
99
|
+
const blobMatch = input.match(GITHUB_BLOB_RE);
|
|
100
|
+
if (blobMatch) {
|
|
101
|
+
const [, owner, repo, branch, pathPrefix] = blobMatch;
|
|
102
|
+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathPrefix ?? ""}SKILL.md`;
|
|
103
|
+
return { type: "file", rawUrl, slug: deriveSlug(repo, pathPrefix) };
|
|
104
|
+
}
|
|
105
|
+
const rawMatch = input.match(RAW_GH_RE);
|
|
106
|
+
if (rawMatch) {
|
|
107
|
+
const [, _owner, repo, _branch, pathPrefix] = rawMatch;
|
|
108
|
+
return { type: "file", rawUrl: input, slug: deriveSlug(repo, pathPrefix) };
|
|
109
|
+
}
|
|
110
|
+
if (GENERIC_SKILL_MD_RE.test(input)) {
|
|
111
|
+
if (!input.startsWith("https://")) {
|
|
112
|
+
throw new Error("Only https:// URLs are supported for SKILL.md installs.");
|
|
113
|
+
}
|
|
114
|
+
const urlObj = new URL(input);
|
|
115
|
+
const segments = urlObj.pathname.split("/").filter(Boolean);
|
|
116
|
+
// Use the segment before SKILL.md, or the hostname as slug fallback
|
|
117
|
+
const slug = segments.length >= 2
|
|
118
|
+
? segments[segments.length - 2]
|
|
119
|
+
: urlObj.hostname.replace(/\./g, "-");
|
|
120
|
+
return { type: "file", rawUrl: input, slug };
|
|
121
|
+
}
|
|
122
|
+
return { type: "repo", url: input };
|
|
123
|
+
}
|
|
124
|
+
async function installSkillFromFile(rawUrl, slug) {
|
|
125
|
+
if (!rawUrl.startsWith("https://")) {
|
|
126
|
+
throw new Error("Only https:// URLs are supported for SKILL.md installs.");
|
|
127
|
+
}
|
|
128
|
+
const destDir = join(SKILLS_DIR, slug);
|
|
129
|
+
if (existsSync(destDir)) {
|
|
130
|
+
throw new Error(`Skill "${slug}" is already installed.`);
|
|
131
|
+
}
|
|
132
|
+
const response = await fetch(rawUrl);
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`Failed to fetch SKILL.md from ${rawUrl} (HTTP ${response.status})`);
|
|
135
|
+
}
|
|
136
|
+
const content = await response.text();
|
|
137
|
+
// Validate: at least one markdown heading in the first 10 lines
|
|
138
|
+
const first10 = content.split(/\r?\n/).slice(0, 10);
|
|
139
|
+
if (!first10.some((line) => /^#\s+/.test(line))) {
|
|
140
|
+
throw new Error("URL does not appear to contain a valid SKILL.md file.");
|
|
141
|
+
}
|
|
142
|
+
mkdirSync(destDir, { recursive: true });
|
|
143
|
+
writeFileSync(join(destDir, "SKILL.md"), content, "utf-8");
|
|
144
|
+
const { name, description } = parseSkillMd(content);
|
|
145
|
+
return {
|
|
146
|
+
name: name || slug,
|
|
147
|
+
slug,
|
|
148
|
+
description,
|
|
149
|
+
path: destDir,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Install a skill from a git repo URL or a direct SKILL.md file URL.
|
|
154
|
+
* Throws if the repo/file does not contain a valid SKILL.md.
|
|
155
|
+
*/
|
|
156
|
+
export async function installSkill(input) {
|
|
157
|
+
const parsed = parseSkillUrl(input);
|
|
158
|
+
if (parsed.type === "file") {
|
|
159
|
+
return installSkillFromFile(parsed.rawUrl, parsed.slug);
|
|
160
|
+
}
|
|
161
|
+
const repoUrl = parsed.url;
|
|
88
162
|
const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
|
|
89
163
|
const destDir = join(SKILLS_DIR, repoName);
|
|
90
164
|
execSync(`git clone ${repoUrl} ${destDir}`, { stdio: "pipe" });
|
package/dist/copilot/tools.js
CHANGED
|
@@ -776,10 +776,10 @@ export function createTools(deps) {
|
|
|
776
776
|
},
|
|
777
777
|
});
|
|
778
778
|
const skillInstall = defineTool("skill_install", {
|
|
779
|
-
description: "Install a skill from a git repository URL.
|
|
779
|
+
description: "Install a skill from a git repository URL or a direct SKILL.md file URL. Accepts full repo URLs (clones the repo) and GitHub blob/raw URLs pointing to a specific SKILL.md (fetches just that file).",
|
|
780
780
|
skipPermission: true,
|
|
781
781
|
parameters: z.object({
|
|
782
|
-
repo_url: z.string().describe("Git repository URL (e.g., https://github.com/user/my-skill.git)"),
|
|
782
|
+
repo_url: z.string().describe("Git repository URL (e.g., https://github.com/user/my-skill.git) or direct SKILL.md URL (e.g., https://github.com/user/repo/blob/main/skills/my-skill/SKILL.md)"),
|
|
783
783
|
}),
|
|
784
784
|
handler: async ({ repo_url }) => {
|
|
785
785
|
console.error(`[io] skill_install called: ${repo_url}`);
|
package/dist/daemon.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { getClient, stopClient } from "./copilot/client.js";
|
|
2
2
|
import { initOrchestrator, sendToOrchestrator, shutdownOrchestrator } from "./copilot/orchestrator.js";
|
|
3
|
-
import { startApiServer, setMessageHandler as setApiHandler } from "./api/server.js";
|
|
4
|
-
import { createBot, startBot, stopBot, sendProactiveMessage, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
|
|
3
|
+
import { startApiServer, setMessageHandler as setApiHandler, broadcastNotificationToSSE } from "./api/server.js";
|
|
4
|
+
import { createBot, startBot, stopBot, sendProactiveMessage, sendBackgroundNotification, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
|
|
5
|
+
import { setTelegramSender, setTuiSender, setSseBroadcaster } from "./notify.js";
|
|
6
|
+
import { pruneOldScheduleRuns } from "./store/schedule-runs.js";
|
|
7
|
+
import { pruneOldNotifications } from "./store/notifications.js";
|
|
8
|
+
import { printBackgroundNotification } from "./tui/index.js";
|
|
5
9
|
import { getDb, closeDb } from "./store/db.js";
|
|
6
10
|
import { clearStaleTasks } from "./store/tasks.js";
|
|
7
11
|
import { reconcileAgentStatuses, reconcileSquadStatuses } from "./store/squads.js";
|
|
@@ -118,6 +122,35 @@ export async function startDaemon() {
|
|
|
118
122
|
startScheduler();
|
|
119
123
|
// Start the IO-level scheduler (squad-independent recurring tasks).
|
|
120
124
|
startIoScheduler();
|
|
125
|
+
// Background-notification dispatch surfaces (issue #78)
|
|
126
|
+
setSseBroadcaster((p) => broadcastNotificationToSSE(p));
|
|
127
|
+
setTuiSender((opts) => printBackgroundNotification(opts));
|
|
128
|
+
setTelegramSender((opts) => sendBackgroundNotification(opts));
|
|
129
|
+
// Daily cleanup — prune schedule runs and notifications older than 30 days
|
|
130
|
+
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
131
|
+
const PRUNE_RETENTION_DAYS = 30;
|
|
132
|
+
const pruneTimer = setInterval(() => {
|
|
133
|
+
try {
|
|
134
|
+
const runsDeleted = pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
|
|
135
|
+
const notificationsDeleted = pruneOldNotifications(PRUNE_RETENTION_DAYS);
|
|
136
|
+
if (runsDeleted > 0 || notificationsDeleted > 0) {
|
|
137
|
+
console.log(`[prune] Cleaned up ${runsDeleted} schedule runs and ${notificationsDeleted} notifications older than ${PRUNE_RETENTION_DAYS} days`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error("[prune] Error during cleanup:", err);
|
|
142
|
+
}
|
|
143
|
+
}, PRUNE_INTERVAL_MS);
|
|
144
|
+
pruneTimer.unref();
|
|
145
|
+
// Run once on startup after a brief delay
|
|
146
|
+
const pruneStartup = setTimeout(() => {
|
|
147
|
+
try {
|
|
148
|
+
pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
|
|
149
|
+
pruneOldNotifications(PRUNE_RETENTION_DAYS);
|
|
150
|
+
}
|
|
151
|
+
catch { /* best effort */ }
|
|
152
|
+
}, 5000);
|
|
153
|
+
pruneStartup.unref?.();
|
|
121
154
|
console.log("[io] IO is fully operational.");
|
|
122
155
|
// Notify Telegram if restarting
|
|
123
156
|
if (config.telegramEnabled && process.env.IO_RESTARTED === "1") {
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { config } from "./config.js";
|
|
2
|
+
import { insertNotification, } from "./store/notifications.js";
|
|
3
|
+
const HEARTBEAT_PATTERNS = [
|
|
4
|
+
/^no active tasks?\.?$/i,
|
|
5
|
+
/^nothing to report\.?$/i,
|
|
6
|
+
/^all clear\.?$/i,
|
|
7
|
+
/^no updates?\.?$/i,
|
|
8
|
+
/^no changes?\.?$/i,
|
|
9
|
+
/^idle\.?$/i,
|
|
10
|
+
/^heartbeat\.?$/i,
|
|
11
|
+
/^ok\.?$/i,
|
|
12
|
+
];
|
|
13
|
+
export function isMeaningfulOutput(text) {
|
|
14
|
+
const trimmed = (text ?? "").trim();
|
|
15
|
+
if (trimmed.length < 20)
|
|
16
|
+
return false;
|
|
17
|
+
const firstLine = trimmed.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
18
|
+
if (HEARTBEAT_PATTERNS.some((re) => re.test(firstLine)))
|
|
19
|
+
return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
let telegramSender;
|
|
23
|
+
let tuiSender;
|
|
24
|
+
let sseBroadcaster;
|
|
25
|
+
export function setTelegramSender(fn) {
|
|
26
|
+
telegramSender = fn;
|
|
27
|
+
}
|
|
28
|
+
export function setTuiSender(fn) {
|
|
29
|
+
tuiSender = fn;
|
|
30
|
+
}
|
|
31
|
+
export function setSseBroadcaster(fn) {
|
|
32
|
+
sseBroadcaster = fn;
|
|
33
|
+
}
|
|
34
|
+
export function _resetNotifySendersForTests() {
|
|
35
|
+
telegramSender = undefined;
|
|
36
|
+
tuiSender = undefined;
|
|
37
|
+
sseBroadcaster = undefined;
|
|
38
|
+
}
|
|
39
|
+
export async function notifyBackground(input) {
|
|
40
|
+
const dispatched = { telegram: false, tui: false, sse: false };
|
|
41
|
+
const text = (input.text ?? "").trim();
|
|
42
|
+
if (text.length === 0)
|
|
43
|
+
return { dispatched, skipped: "empty" };
|
|
44
|
+
const mode = config.backgroundNotifyMode ?? "meaningful";
|
|
45
|
+
if (mode === "off")
|
|
46
|
+
return { dispatched, skipped: "off" };
|
|
47
|
+
if (mode === "meaningful" && !isMeaningfulOutput(text)) {
|
|
48
|
+
return { dispatched, skipped: "not-meaningful" };
|
|
49
|
+
}
|
|
50
|
+
const { source, title } = input;
|
|
51
|
+
const sourceRefJson = JSON.stringify(stripType(source));
|
|
52
|
+
let row;
|
|
53
|
+
try {
|
|
54
|
+
row = insertNotification({
|
|
55
|
+
source_type: source.type,
|
|
56
|
+
source_ref: sourceRefJson === "{}" ? null : sourceRefJson,
|
|
57
|
+
title,
|
|
58
|
+
text,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error("[notify] failed to persist notification:", err);
|
|
63
|
+
return { dispatched };
|
|
64
|
+
}
|
|
65
|
+
if (sseBroadcaster) {
|
|
66
|
+
try {
|
|
67
|
+
sseBroadcaster({
|
|
68
|
+
id: row.id,
|
|
69
|
+
source: { type: source.type, ...stripType(source) },
|
|
70
|
+
title,
|
|
71
|
+
text,
|
|
72
|
+
createdAt: row.created_at,
|
|
73
|
+
});
|
|
74
|
+
dispatched.sse = true;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error("[notify] sse broadcast failed:", err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (tuiSender && (config.backgroundNotifyTui ?? true)) {
|
|
81
|
+
try {
|
|
82
|
+
tuiSender({ title, text });
|
|
83
|
+
dispatched.tui = true;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.error("[notify] tui send failed:", err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (telegramSender && (config.backgroundNotifyTelegram ?? true)) {
|
|
90
|
+
try {
|
|
91
|
+
await telegramSender({ title, text });
|
|
92
|
+
dispatched.telegram = true;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
console.error("[notify] telegram send failed:", err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { id: row.id, dispatched };
|
|
99
|
+
}
|
|
100
|
+
function stripType(s) {
|
|
101
|
+
const { type: _t, ...rest } = s;
|
|
102
|
+
void _t;
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=notify.js.map
|
|
@@ -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 (
|
|
@@ -115,6 +131,29 @@ SELECT agent_slug,
|
|
|
115
131
|
MAX(started_at) AS last_delegated_at
|
|
116
132
|
FROM agent_tasks
|
|
117
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)`,
|
|
118
157
|
];
|
|
119
158
|
for (const migration of migrations) {
|
|
120
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
|