heyio 0.42.0 → 1.0.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/README.md +40 -52
- package/dist/api/auth.js +35 -38
- package/dist/api/server.js +157 -1139
- package/dist/config.js +49 -32
- package/dist/copilot/agents.js +72 -1055
- package/dist/copilot/client.js +6 -17
- package/dist/copilot/io-scheduler.js +55 -139
- package/dist/copilot/model-router.js +100 -72
- package/dist/copilot/orchestrator.js +91 -515
- package/dist/copilot/scheduler.js +67 -189
- package/dist/copilot/skills.js +41 -366
- package/dist/copilot/system-message.js +40 -200
- package/dist/copilot/tools.js +191 -2042
- package/dist/daemon.js +54 -201
- package/dist/index.js +15 -133
- package/dist/mcp/config.js +23 -31
- package/dist/mcp/index.js +2 -3
- package/dist/mcp/registry.js +33 -88
- package/dist/notify.js +18 -100
- package/dist/paths.js +13 -24
- package/dist/setup.js +35 -0
- package/dist/store/db.js +111 -297
- package/dist/store/feed.js +29 -97
- package/dist/store/instances.js +56 -121
- package/dist/store/schedules.js +21 -73
- package/dist/store/squads.js +35 -186
- package/dist/store/tasks.js +25 -168
- package/dist/telegram/bot.js +20 -312
- package/dist/telegram/handlers.js +39 -3
- package/dist/watchdog.js +31 -45
- package/dist/wiki/fs.js +38 -155
- package/dist/wiki/search.js +31 -44
- package/package.json +5 -8
- package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
- package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
- package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
- package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
- package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
- package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
- package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
- package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
- package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
- package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
- package/web-dist/assets/api-WGvTsXaE.js +1 -0
- package/web-dist/assets/index-D7M5O-_l.css +1 -0
- package/web-dist/assets/index-DZOS9syn.js +95 -0
- package/web-dist/assets/plus-BOvyX1BC.js +6 -0
- package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
- package/web-dist/favicon.svg +4 -1
- package/web-dist/index.html +7 -10
- package/dist/api/logout.test.js +0 -129
- package/dist/api/mcp.test.js +0 -285
- package/dist/api/wiki.test.js +0 -283
- package/dist/auth/session-logic.js +0 -79
- package/dist/auth/session-logic.test.js +0 -201
- package/dist/copilot/auto-complete-instance.test.js +0 -104
- package/dist/copilot/cron.js +0 -136
- package/dist/copilot/event-summary.js +0 -286
- package/dist/copilot/instance-deactivate.test.js +0 -119
- package/dist/copilot/model-router.test.js +0 -71
- package/dist/copilot/review-backfill.js +0 -57
- package/dist/copilot/session-timeout.js +0 -112
- package/dist/copilot/session-timeout.test.js +0 -372
- package/dist/copilot/skills.test.js +0 -55
- package/dist/copilot/universes.js +0 -469
- package/dist/instance-watchdog.js +0 -104
- package/dist/instance-watchdog.test.js +0 -183
- package/dist/mcp/client.js +0 -109
- package/dist/mcp/client.test.js +0 -99
- package/dist/mcp/config.test.js +0 -49
- package/dist/mcp/registry.test.js +0 -79
- package/dist/notify.test.js +0 -232
- package/dist/store/feed.test.js +0 -279
- package/dist/store/instances.test.js +0 -310
- package/dist/store/io-schedules.js +0 -63
- package/dist/store/notifications.js +0 -79
- package/dist/store/notifications.test.js +0 -197
- package/dist/store/schedule-runs.js +0 -46
- package/dist/store/squads.test.js +0 -405
- package/dist/store/tasks.test.js +0 -150
- package/dist/store/worktrees.js +0 -83
- package/dist/tui/index.js +0 -286
- package/dist/update.js +0 -81
- package/dist/watchdog.test.js +0 -83
- package/dist/wiki/wiki-squad.test.js +0 -54
- package/web-dist/assets/AgentActivityView-CedxxE6K.js +0 -1
- package/web-dist/assets/ChatView-DMkYQo_V.js +0 -4
- package/web-dist/assets/FeedView-BH4q-31V.js +0 -1
- package/web-dist/assets/InboxView-BVwVP4EW.js +0 -1
- package/web-dist/assets/LoginView-DRPDhnwu.js +0 -1
- package/web-dist/assets/McpView-D8yWz-lq.js +0 -1
- package/web-dist/assets/SchedulesView-BzzyncGF.js +0 -1
- package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-oW3ySu7Y.js +0 -1
- package/web-dist/assets/SkillsView-oxpYuhx7.js +0 -1
- package/web-dist/assets/SquadsView-CaKUIKlq.js +0 -1
- package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-8U15Qp_Q.js +0 -1
- package/web-dist/assets/WikiView-C5jXUlfW.js +0 -1
- package/web-dist/assets/index-BrWzNw-N.css +0 -10
- package/web-dist/assets/index-f67odrrt.js +0 -81
- package/web-dist/icons.svg +0 -24
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for src/store/instances.ts — squad instances store.
|
|
3
|
-
*
|
|
4
|
-
* DB isolation: setDbPathForTests() redirects the SQLite singleton to a
|
|
5
|
-
* fresh tmp file so 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 { createInstance, getInstance, listInstances, updateInstanceStatus, logInstanceDecision, getInstanceDecisions, mergeInstanceDecisions, deleteInstance, buildContextSnapshot, reconcileInstances, MAX_CONCURRENT_INSTANCES, } from "./instances.js";
|
|
14
|
-
import { logDecision, getDecisions } from "./squads.js";
|
|
15
|
-
// ── DB isolation ─────────────────────────────────────────────────────────────
|
|
16
|
-
let tmpDir;
|
|
17
|
-
before(() => {
|
|
18
|
-
tmpDir = mkdtempSync(join(tmpdir(), "io-instances-test-"));
|
|
19
|
-
setDbPathForTests(join(tmpDir, "io.db"));
|
|
20
|
-
});
|
|
21
|
-
after(() => {
|
|
22
|
-
closeDb();
|
|
23
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
-
});
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
const db = getDb();
|
|
27
|
-
db.prepare("DELETE FROM instance_decisions").run();
|
|
28
|
-
db.prepare("DELETE FROM squad_instances").run();
|
|
29
|
-
db.prepare("DELETE FROM squad_decisions").run();
|
|
30
|
-
db.prepare("DELETE FROM squads").run();
|
|
31
|
-
db.prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("test-squad", "Test Squad", "/tmp/test");
|
|
32
|
-
});
|
|
33
|
-
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
34
|
-
function makeInstance(id, slug = "test-squad", status) {
|
|
35
|
-
const inst = createInstance({
|
|
36
|
-
id,
|
|
37
|
-
masterSquadSlug: slug,
|
|
38
|
-
issueRef: `#${id}`,
|
|
39
|
-
worktreePath: `/tmp/nonexistent-worktree-${id}`,
|
|
40
|
-
branchName: `${slug}/instance/${id}`,
|
|
41
|
-
});
|
|
42
|
-
if (status && status !== "pending") {
|
|
43
|
-
updateInstanceStatus(id, status);
|
|
44
|
-
}
|
|
45
|
-
return inst;
|
|
46
|
-
}
|
|
47
|
-
// ── createInstance ────────────────────────────────────────────────────────────
|
|
48
|
-
describe("createInstance", () => {
|
|
49
|
-
it("creates and returns an instance with correct fields", () => {
|
|
50
|
-
const inst = createInstance({
|
|
51
|
-
id: "inst-1",
|
|
52
|
-
masterSquadSlug: "test-squad",
|
|
53
|
-
issueRef: "#42",
|
|
54
|
-
worktreePath: "/tmp/wt/inst-1",
|
|
55
|
-
branchName: "test-squad/instance/inst-1",
|
|
56
|
-
contextSnapshot: JSON.stringify([{ decision: "use TypeScript" }]),
|
|
57
|
-
});
|
|
58
|
-
assert.equal(inst.id, "inst-1");
|
|
59
|
-
assert.equal(inst.master_squad_slug, "test-squad");
|
|
60
|
-
assert.equal(inst.issue_ref, "#42");
|
|
61
|
-
assert.equal(inst.worktree_path, "/tmp/wt/inst-1");
|
|
62
|
-
assert.equal(inst.branch_name, "test-squad/instance/inst-1");
|
|
63
|
-
assert.equal(inst.status, "pending");
|
|
64
|
-
assert.ok(inst.context_snapshot?.includes("use TypeScript"));
|
|
65
|
-
assert.equal(inst.completed_at, null);
|
|
66
|
-
assert.ok(inst.created_at);
|
|
67
|
-
});
|
|
68
|
-
it("throws when max concurrent instances are exceeded", () => {
|
|
69
|
-
// Create MAX_CONCURRENT_INSTANCES active instances
|
|
70
|
-
for (let i = 1; i <= MAX_CONCURRENT_INSTANCES; i++) {
|
|
71
|
-
createInstance({
|
|
72
|
-
id: `inst-max-${i}`,
|
|
73
|
-
masterSquadSlug: "test-squad",
|
|
74
|
-
worktreePath: `/tmp/wt/inst-max-${i}`,
|
|
75
|
-
branchName: `test-squad/instance/inst-max-${i}`,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
assert.throws(() => createInstance({
|
|
79
|
-
id: "inst-over-limit",
|
|
80
|
-
masterSquadSlug: "test-squad",
|
|
81
|
-
worktreePath: "/tmp/wt/inst-over-limit",
|
|
82
|
-
branchName: "test-squad/instance/inst-over-limit",
|
|
83
|
-
}), /Max concurrent instances/);
|
|
84
|
-
});
|
|
85
|
-
it("does not count done/failed instances toward the limit", () => {
|
|
86
|
-
for (let i = 1; i <= MAX_CONCURRENT_INSTANCES; i++) {
|
|
87
|
-
const inst = createInstance({
|
|
88
|
-
id: `inst-done-${i}`,
|
|
89
|
-
masterSquadSlug: "test-squad",
|
|
90
|
-
worktreePath: `/tmp/wt/inst-done-${i}`,
|
|
91
|
-
branchName: `test-squad/instance/inst-done-${i}`,
|
|
92
|
-
});
|
|
93
|
-
updateInstanceStatus(inst.id, "done");
|
|
94
|
-
}
|
|
95
|
-
// Should not throw — all prior instances are done
|
|
96
|
-
assert.doesNotThrow(() => createInstance({
|
|
97
|
-
id: "inst-after-done",
|
|
98
|
-
masterSquadSlug: "test-squad",
|
|
99
|
-
worktreePath: "/tmp/wt/inst-after-done",
|
|
100
|
-
branchName: "test-squad/instance/inst-after-done",
|
|
101
|
-
}));
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
// ── getInstance ───────────────────────────────────────────────────────────────
|
|
105
|
-
describe("getInstance", () => {
|
|
106
|
-
it("returns undefined for non-existent ID", () => {
|
|
107
|
-
assert.equal(getInstance("no-such-id"), undefined);
|
|
108
|
-
});
|
|
109
|
-
it("returns the correct instance after create", () => {
|
|
110
|
-
makeInstance("get-test");
|
|
111
|
-
const inst = getInstance("get-test");
|
|
112
|
-
assert.ok(inst);
|
|
113
|
-
assert.equal(inst.id, "get-test");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
// ── listInstances ─────────────────────────────────────────────────────────────
|
|
117
|
-
describe("listInstances", () => {
|
|
118
|
-
it("filters by slug and excludes done/failed by default", () => {
|
|
119
|
-
makeInstance("li-active-1");
|
|
120
|
-
makeInstance("li-active-2");
|
|
121
|
-
makeInstance("li-done", "test-squad", "done");
|
|
122
|
-
makeInstance("li-failed", "test-squad", "failed");
|
|
123
|
-
const active = listInstances("test-squad");
|
|
124
|
-
assert.equal(active.length, 2);
|
|
125
|
-
assert.ok(active.every((i) => i.status === "pending"));
|
|
126
|
-
});
|
|
127
|
-
it("includes completed instances when opted in", () => {
|
|
128
|
-
makeInstance("li-pending");
|
|
129
|
-
makeInstance("li-done2", "test-squad", "done");
|
|
130
|
-
const all = listInstances("test-squad", { includeCompleted: true });
|
|
131
|
-
assert.equal(all.length, 2);
|
|
132
|
-
});
|
|
133
|
-
it("does not include instances from other squads", () => {
|
|
134
|
-
getDb().prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("other-squad", "Other", "/tmp/other");
|
|
135
|
-
makeInstance("li-other", "other-squad");
|
|
136
|
-
makeInstance("li-mine");
|
|
137
|
-
const mine = listInstances("test-squad");
|
|
138
|
-
assert.equal(mine.length, 1);
|
|
139
|
-
assert.equal(mine[0].id, "li-mine");
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
// ── updateInstanceStatus ──────────────────────────────────────────────────────
|
|
143
|
-
describe("updateInstanceStatus", () => {
|
|
144
|
-
it("sets completed_at for terminal status 'done'", () => {
|
|
145
|
-
makeInstance("upd-done");
|
|
146
|
-
updateInstanceStatus("upd-done", "done");
|
|
147
|
-
const inst = getInstance("upd-done");
|
|
148
|
-
assert.equal(inst.status, "done");
|
|
149
|
-
assert.ok(inst.completed_at !== null);
|
|
150
|
-
});
|
|
151
|
-
it("sets completed_at for terminal status 'failed'", () => {
|
|
152
|
-
makeInstance("upd-failed");
|
|
153
|
-
updateInstanceStatus("upd-failed", "failed");
|
|
154
|
-
const inst = getInstance("upd-failed");
|
|
155
|
-
assert.equal(inst.status, "failed");
|
|
156
|
-
assert.ok(inst.completed_at !== null);
|
|
157
|
-
});
|
|
158
|
-
it("does not set completed_at for non-terminal status 'active'", () => {
|
|
159
|
-
makeInstance("upd-active");
|
|
160
|
-
updateInstanceStatus("upd-active", "active");
|
|
161
|
-
const inst = getInstance("upd-active");
|
|
162
|
-
assert.equal(inst.status, "active");
|
|
163
|
-
assert.equal(inst.completed_at, null);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
// ── logInstanceDecision + getInstanceDecisions ────────────────────────────────
|
|
167
|
-
describe("logInstanceDecision / getInstanceDecisions", () => {
|
|
168
|
-
it("round-trips a decision with context", () => {
|
|
169
|
-
makeInstance("dec-inst");
|
|
170
|
-
logInstanceDecision("dec-inst", "use Jest for tests", "better DX");
|
|
171
|
-
const decisions = getInstanceDecisions("dec-inst");
|
|
172
|
-
assert.equal(decisions.length, 1);
|
|
173
|
-
assert.equal(decisions[0].decision, "use Jest for tests");
|
|
174
|
-
assert.equal(decisions[0].context, "better DX");
|
|
175
|
-
assert.equal(decisions[0].merged_to_master, 0);
|
|
176
|
-
});
|
|
177
|
-
it("stores null context when not provided", () => {
|
|
178
|
-
makeInstance("dec-nocontext");
|
|
179
|
-
logInstanceDecision("dec-nocontext", "some decision");
|
|
180
|
-
const decisions = getInstanceDecisions("dec-nocontext");
|
|
181
|
-
assert.equal(decisions[0].context, null);
|
|
182
|
-
});
|
|
183
|
-
it("returns decisions in ascending created_at order", () => {
|
|
184
|
-
makeInstance("dec-order");
|
|
185
|
-
logInstanceDecision("dec-order", "first");
|
|
186
|
-
logInstanceDecision("dec-order", "second");
|
|
187
|
-
const decisions = getInstanceDecisions("dec-order");
|
|
188
|
-
assert.equal(decisions[0].decision, "first");
|
|
189
|
-
assert.equal(decisions[1].decision, "second");
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
// ── mergeInstanceDecisions ────────────────────────────────────────────────────
|
|
193
|
-
describe("mergeInstanceDecisions", () => {
|
|
194
|
-
it("copies decisions to squad_decisions with provenance tag and marks merged", () => {
|
|
195
|
-
makeInstance("merge-inst");
|
|
196
|
-
logInstanceDecision("merge-inst", "use SQLite transactions", "performance");
|
|
197
|
-
logInstanceDecision("merge-inst", "append-only log", "conflict-free");
|
|
198
|
-
const count = mergeInstanceDecisions("merge-inst", "test-squad");
|
|
199
|
-
assert.equal(count, 2);
|
|
200
|
-
const masterDecisions = getDecisions("test-squad");
|
|
201
|
-
assert.equal(masterDecisions.length, 2);
|
|
202
|
-
assert.ok(masterDecisions.some((d) => d.decision === "use SQLite transactions"));
|
|
203
|
-
assert.ok(masterDecisions.every((d) => d.context?.includes("[from instance: merge-inst]")));
|
|
204
|
-
const instDecisions = getInstanceDecisions("merge-inst");
|
|
205
|
-
assert.ok(instDecisions.every((d) => d.merged_to_master === 1));
|
|
206
|
-
});
|
|
207
|
-
it("is idempotent — calling twice does not double-merge", () => {
|
|
208
|
-
makeInstance("merge-idempotent");
|
|
209
|
-
logInstanceDecision("merge-idempotent", "idempotent decision");
|
|
210
|
-
mergeInstanceDecisions("merge-idempotent", "test-squad");
|
|
211
|
-
const second = mergeInstanceDecisions("merge-idempotent", "test-squad");
|
|
212
|
-
assert.equal(second, 0);
|
|
213
|
-
const masterDecisions = getDecisions("test-squad");
|
|
214
|
-
assert.equal(masterDecisions.length, 1);
|
|
215
|
-
});
|
|
216
|
-
it("returns 0 when there are no decisions to merge", () => {
|
|
217
|
-
makeInstance("merge-empty");
|
|
218
|
-
const count = mergeInstanceDecisions("merge-empty", "test-squad");
|
|
219
|
-
assert.equal(count, 0);
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
// ── deleteInstance ────────────────────────────────────────────────────────────
|
|
223
|
-
describe("deleteInstance", () => {
|
|
224
|
-
it("removes the instance and its decisions", () => {
|
|
225
|
-
makeInstance("del-inst");
|
|
226
|
-
logInstanceDecision("del-inst", "a decision");
|
|
227
|
-
deleteInstance("del-inst");
|
|
228
|
-
assert.equal(getInstance("del-inst"), undefined);
|
|
229
|
-
assert.deepEqual(getInstanceDecisions("del-inst"), []);
|
|
230
|
-
});
|
|
231
|
-
it("is safe to call on a non-existent id", () => {
|
|
232
|
-
assert.doesNotThrow(() => deleteInstance("no-such-instance"));
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
// ── buildContextSnapshot ──────────────────────────────────────────────────────
|
|
236
|
-
describe("buildContextSnapshot", () => {
|
|
237
|
-
it("returns a JSON object with decisions array", () => {
|
|
238
|
-
logDecision("test-squad", "use TypeScript everywhere", "consistency");
|
|
239
|
-
logDecision("test-squad", "prefer functional style");
|
|
240
|
-
const snapshot = buildContextSnapshot("test-squad");
|
|
241
|
-
const parsed = JSON.parse(snapshot);
|
|
242
|
-
assert.ok(Array.isArray(parsed.decisions));
|
|
243
|
-
assert.equal(parsed.decisions.length, 2);
|
|
244
|
-
assert.ok(parsed.decisions.some((d) => d.decision === "use TypeScript everywhere"));
|
|
245
|
-
});
|
|
246
|
-
it("returns empty decisions array for a squad with no decisions", () => {
|
|
247
|
-
const snapshot = buildContextSnapshot("test-squad");
|
|
248
|
-
const parsed = JSON.parse(snapshot);
|
|
249
|
-
assert.deepEqual(parsed.decisions, []);
|
|
250
|
-
});
|
|
251
|
-
it("respects the limit parameter", () => {
|
|
252
|
-
for (let i = 0; i < 10; i++) {
|
|
253
|
-
logDecision("test-squad", `decision ${i}`);
|
|
254
|
-
}
|
|
255
|
-
const snapshot = buildContextSnapshot("test-squad", 5);
|
|
256
|
-
const parsed = JSON.parse(snapshot);
|
|
257
|
-
assert.equal(parsed.decisions.length, 5);
|
|
258
|
-
});
|
|
259
|
-
it("includes wiki pages when they exist", async () => {
|
|
260
|
-
const { writePage, deletePage } = await import("../wiki/fs.js");
|
|
261
|
-
const testSlug = `test-squad-snap-${Date.now()}`;
|
|
262
|
-
const pagePath = `pages/squads/${testSlug}/rules.md`;
|
|
263
|
-
try {
|
|
264
|
-
writePage(pagePath, "# Rules\nNo force push.");
|
|
265
|
-
logDecision(testSlug, "test decision");
|
|
266
|
-
const snapshot = buildContextSnapshot(testSlug);
|
|
267
|
-
const parsed = JSON.parse(snapshot);
|
|
268
|
-
assert.ok(parsed.wiki);
|
|
269
|
-
assert.equal(parsed.wiki.length, 1);
|
|
270
|
-
assert.equal(parsed.wiki[0].path, pagePath);
|
|
271
|
-
assert.ok(parsed.wiki[0].content.includes("No force push"));
|
|
272
|
-
}
|
|
273
|
-
finally {
|
|
274
|
-
deletePage(pagePath);
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
// ── reconcileInstances ────────────────────────────────────────────────────────
|
|
279
|
-
describe("reconcileInstances", () => {
|
|
280
|
-
it("marks pending/active/merging instances failed when worktree does not exist", () => {
|
|
281
|
-
// These worktree paths do not exist on disk
|
|
282
|
-
makeInstance("recon-pending"); // status: pending
|
|
283
|
-
makeInstance("recon-active", "test-squad", "active"); // status: active
|
|
284
|
-
makeInstance("recon-merging", "test-squad", "merging"); // status: merging
|
|
285
|
-
const cleaned = reconcileInstances();
|
|
286
|
-
assert.equal(cleaned, 3);
|
|
287
|
-
assert.equal(getInstance("recon-pending").status, "failed");
|
|
288
|
-
assert.equal(getInstance("recon-active").status, "failed");
|
|
289
|
-
assert.equal(getInstance("recon-merging").status, "failed");
|
|
290
|
-
});
|
|
291
|
-
it("does not touch already-terminal instances", () => {
|
|
292
|
-
makeInstance("recon-done", "test-squad", "done");
|
|
293
|
-
makeInstance("recon-failed", "test-squad", "failed");
|
|
294
|
-
const cleaned = reconcileInstances();
|
|
295
|
-
assert.equal(cleaned, 0);
|
|
296
|
-
});
|
|
297
|
-
it("returns 0 when all non-terminal instances have valid worktrees", () => {
|
|
298
|
-
// Use a path that actually exists (tmpDir was created above)
|
|
299
|
-
createInstance({
|
|
300
|
-
id: "recon-exists",
|
|
301
|
-
masterSquadSlug: "test-squad",
|
|
302
|
-
worktreePath: tmpDir, // this path exists on disk
|
|
303
|
-
branchName: "test-squad/instance/recon-exists",
|
|
304
|
-
});
|
|
305
|
-
const cleaned = reconcileInstances();
|
|
306
|
-
assert.equal(cleaned, 0);
|
|
307
|
-
assert.equal(getInstance("recon-exists").status, "pending");
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
//# sourceMappingURL=instances.test.js.map
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { getDb } from "./db.js";
|
|
2
|
-
export function createIoSchedule(input) {
|
|
3
|
-
const db = getDb();
|
|
4
|
-
const info = db
|
|
5
|
-
.prepare(`INSERT INTO io_schedules
|
|
6
|
-
(name, cron_expr, prompt, notes, enabled, next_run_at)
|
|
7
|
-
VALUES (?, ?, ?, ?, 1, ?)`)
|
|
8
|
-
.run(input.name, input.cronExpr, input.prompt, input.notes ?? null, input.nextRunAt);
|
|
9
|
-
const id = Number(info.lastInsertRowid);
|
|
10
|
-
return getIoSchedule(id);
|
|
11
|
-
}
|
|
12
|
-
export function getIoSchedule(id) {
|
|
13
|
-
return getDb()
|
|
14
|
-
.prepare("SELECT * FROM io_schedules WHERE id = ?")
|
|
15
|
-
.get(id);
|
|
16
|
-
}
|
|
17
|
-
export function listIoSchedules() {
|
|
18
|
-
return getDb()
|
|
19
|
-
.prepare("SELECT * FROM io_schedules ORDER BY id ASC")
|
|
20
|
-
.all();
|
|
21
|
-
}
|
|
22
|
-
export function listDueIoSchedules(now) {
|
|
23
|
-
return getDb()
|
|
24
|
-
.prepare(`SELECT * FROM io_schedules
|
|
25
|
-
WHERE enabled = 1
|
|
26
|
-
AND next_run_at IS NOT NULL
|
|
27
|
-
AND next_run_at <= ?
|
|
28
|
-
ORDER BY next_run_at ASC`)
|
|
29
|
-
.all(now.toISOString());
|
|
30
|
-
}
|
|
31
|
-
export function deleteIoSchedule(id) {
|
|
32
|
-
const info = getDb()
|
|
33
|
-
.prepare("DELETE FROM io_schedules WHERE id = ?")
|
|
34
|
-
.run(id);
|
|
35
|
-
return info.changes > 0;
|
|
36
|
-
}
|
|
37
|
-
export function setIoScheduleEnabled(id, enabled) {
|
|
38
|
-
const info = getDb()
|
|
39
|
-
.prepare("UPDATE io_schedules SET enabled = ? WHERE id = ?")
|
|
40
|
-
.run(enabled ? 1 : 0, id);
|
|
41
|
-
return info.changes > 0;
|
|
42
|
-
}
|
|
43
|
-
export function recordIoScheduleRun(id, ranAt, nextRunAt) {
|
|
44
|
-
getDb()
|
|
45
|
-
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
46
|
-
.run(ranAt.toISOString(), nextRunAt, id);
|
|
47
|
-
}
|
|
48
|
-
export function updateIoScheduleNextRun(id, nextRunAt) {
|
|
49
|
-
getDb()
|
|
50
|
-
.prepare("UPDATE io_schedules SET next_run_at = ? WHERE id = ?")
|
|
51
|
-
.run(nextRunAt, id);
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Overwrite both last_run_at and next_run_at directly. Unlike
|
|
55
|
-
* recordIoScheduleRun this accepts NULL for last_run_at, which is needed when
|
|
56
|
-
* restoring a schedule's "never run" state after a manual run_now.
|
|
57
|
-
*/
|
|
58
|
-
export function setIoScheduleTimestamps(id, lastRunAt, nextRunAt) {
|
|
59
|
-
getDb()
|
|
60
|
-
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
61
|
-
.run(lastRunAt, nextRunAt, id);
|
|
62
|
-
}
|
|
63
|
-
//# sourceMappingURL=io-schedules.js.map
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
|
@@ -1,197 +0,0 @@
|
|
|
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
|