heyio 0.29.0 → 0.30.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 +119 -1
- package/dist/copilot/agents.js +2 -2
- package/dist/copilot/orchestrator.js +24 -1
- package/dist/copilot/tools.js +183 -2
- package/dist/instance-watchdog.js +70 -0
- package/dist/instance-watchdog.test.js +112 -0
- package/dist/store/db.js +21 -0
- package/dist/store/instances.js +131 -0
- package/dist/store/instances.test.js +291 -0
- package/dist/store/tasks.js +2 -2
- package/dist/store/tasks.test.js +150 -0
- package/dist/store/worktrees.js +83 -0
- package/package.json +1 -1
- package/web-dist/assets/index-D3uXBVcQ.js +88 -0
- package/web-dist/assets/index-DmthMbtN.css +10 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-CM-_9_BJ.css +0 -10
- package/web-dist/assets/index-CghlzlHJ.js +0 -88
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
import { getDecisions } from "./squads.js";
|
|
3
|
+
import { worktreeExists } from "./worktrees.js";
|
|
4
|
+
export function ensureInstanceTables() {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
db.exec(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS squad_instances (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
master_squad_slug TEXT NOT NULL,
|
|
10
|
+
issue_ref TEXT,
|
|
11
|
+
worktree_path TEXT NOT NULL,
|
|
12
|
+
branch_name TEXT NOT NULL,
|
|
13
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
14
|
+
context_snapshot TEXT,
|
|
15
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
16
|
+
completed_at DATETIME
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS instance_decisions (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
instance_id TEXT NOT NULL,
|
|
22
|
+
decision TEXT NOT NULL,
|
|
23
|
+
context TEXT,
|
|
24
|
+
merged_to_master INTEGER DEFAULT 0,
|
|
25
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
26
|
+
);
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
export const MAX_CONCURRENT_INSTANCES = 3;
|
|
30
|
+
export function createInstance(input) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const activeCount = db
|
|
33
|
+
.prepare("SELECT COUNT(*) as cnt FROM squad_instances WHERE master_squad_slug = ? AND status NOT IN ('done', 'failed')")
|
|
34
|
+
.get(input.masterSquadSlug).cnt;
|
|
35
|
+
if (activeCount >= MAX_CONCURRENT_INSTANCES) {
|
|
36
|
+
throw new Error(`Max concurrent instances (${MAX_CONCURRENT_INSTANCES}) reached for squad "${input.masterSquadSlug}"`);
|
|
37
|
+
}
|
|
38
|
+
db.prepare(`INSERT INTO squad_instances (id, master_squad_slug, issue_ref, worktree_path, branch_name, status, context_snapshot)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?)`).run(input.id, input.masterSquadSlug, input.issueRef ?? null, input.worktreePath, input.branchName, input.contextSnapshot ?? null);
|
|
40
|
+
return getInstance(input.id);
|
|
41
|
+
}
|
|
42
|
+
export function getInstance(id) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
return db.prepare("SELECT * FROM squad_instances WHERE id = ?").get(id);
|
|
45
|
+
}
|
|
46
|
+
export function listInstances(masterSquadSlug, opts) {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
const includeCompleted = opts?.includeCompleted ?? false;
|
|
49
|
+
if (includeCompleted) {
|
|
50
|
+
return db
|
|
51
|
+
.prepare("SELECT id, issue_ref, status, branch_name, created_at, completed_at FROM squad_instances WHERE master_squad_slug = ? ORDER BY created_at DESC")
|
|
52
|
+
.all(masterSquadSlug);
|
|
53
|
+
}
|
|
54
|
+
return db
|
|
55
|
+
.prepare("SELECT id, issue_ref, status, branch_name, created_at, completed_at FROM squad_instances WHERE master_squad_slug = ? AND status NOT IN ('done', 'failed') ORDER BY created_at DESC")
|
|
56
|
+
.all(masterSquadSlug);
|
|
57
|
+
}
|
|
58
|
+
export function updateInstanceStatus(id, status) {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
if (status === "done" || status === "failed") {
|
|
61
|
+
db.prepare("UPDATE squad_instances SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, id);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
db.prepare("UPDATE squad_instances SET status = ? WHERE id = ?").run(status, id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function logInstanceDecision(instanceId, decision, context) {
|
|
68
|
+
const db = getDb();
|
|
69
|
+
db.prepare("INSERT INTO instance_decisions (instance_id, decision, context) VALUES (?, ?, ?)").run(instanceId, decision, context ?? null);
|
|
70
|
+
}
|
|
71
|
+
export function getInstanceDecisions(instanceId) {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
return db
|
|
74
|
+
.prepare("SELECT decision, context, created_at, merged_to_master FROM instance_decisions WHERE instance_id = ? ORDER BY created_at ASC")
|
|
75
|
+
.all(instanceId);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Merge instance decisions back to master squad. Returns count merged.
|
|
79
|
+
*/
|
|
80
|
+
export function mergeInstanceDecisions(instanceId, masterSquadSlug) {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
const decisions = db
|
|
83
|
+
.prepare("SELECT id, decision, context FROM instance_decisions WHERE instance_id = ? AND merged_to_master = 0")
|
|
84
|
+
.all(instanceId);
|
|
85
|
+
if (decisions.length === 0)
|
|
86
|
+
return 0;
|
|
87
|
+
const insertStmt = db.prepare("INSERT INTO squad_decisions (squad_slug, decision, context) VALUES (?, ?, ?)");
|
|
88
|
+
const markStmt = db.prepare("UPDATE instance_decisions SET merged_to_master = 1 WHERE id = ?");
|
|
89
|
+
const mergeAll = db.transaction(() => {
|
|
90
|
+
for (const d of decisions) {
|
|
91
|
+
const ctx = d.context
|
|
92
|
+
? `${d.context} [from instance: ${instanceId}]`
|
|
93
|
+
: `[from instance: ${instanceId}]`;
|
|
94
|
+
insertStmt.run(masterSquadSlug, d.decision, ctx);
|
|
95
|
+
markStmt.run(d.id);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
mergeAll();
|
|
99
|
+
return decisions.length;
|
|
100
|
+
}
|
|
101
|
+
export function deleteInstance(id) {
|
|
102
|
+
const db = getDb();
|
|
103
|
+
db.prepare("DELETE FROM instance_decisions WHERE instance_id = ?").run(id);
|
|
104
|
+
db.prepare("DELETE FROM squad_instances WHERE id = ?").run(id);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build a JSON snapshot of the master squad's recent decisions for context inheritance.
|
|
108
|
+
*/
|
|
109
|
+
export function buildContextSnapshot(masterSquadSlug, limit = 30) {
|
|
110
|
+
const decisions = getDecisions(masterSquadSlug, limit);
|
|
111
|
+
return JSON.stringify(decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Reconcile instances on startup: detect orphaned worktrees and mark stale active instances.
|
|
115
|
+
* Returns the number of instances cleaned up.
|
|
116
|
+
*/
|
|
117
|
+
export function reconcileInstances() {
|
|
118
|
+
const db = getDb();
|
|
119
|
+
const activeInstances = db
|
|
120
|
+
.prepare("SELECT id, worktree_path FROM squad_instances WHERE status IN ('active', 'pending', 'merging')")
|
|
121
|
+
.all();
|
|
122
|
+
let cleaned = 0;
|
|
123
|
+
for (const inst of activeInstances) {
|
|
124
|
+
if (!worktreeExists(inst.worktree_path)) {
|
|
125
|
+
updateInstanceStatus(inst.id, "failed");
|
|
126
|
+
cleaned++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return cleaned;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=instances.js.map
|
|
@@ -0,0 +1,291 @@
|
|
|
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 array of recent squad decisions", () => {
|
|
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));
|
|
243
|
+
assert.equal(parsed.length, 2);
|
|
244
|
+
assert.ok(parsed.some((d) => d.decision === "use TypeScript everywhere"));
|
|
245
|
+
});
|
|
246
|
+
it("returns an empty JSON array for a squad with no decisions", () => {
|
|
247
|
+
const snapshot = buildContextSnapshot("test-squad");
|
|
248
|
+
assert.deepEqual(JSON.parse(snapshot), []);
|
|
249
|
+
});
|
|
250
|
+
it("respects the limit parameter", () => {
|
|
251
|
+
for (let i = 0; i < 10; i++) {
|
|
252
|
+
logDecision("test-squad", `decision ${i}`);
|
|
253
|
+
}
|
|
254
|
+
const snapshot = buildContextSnapshot("test-squad", 5);
|
|
255
|
+
const parsed = JSON.parse(snapshot);
|
|
256
|
+
assert.equal(parsed.length, 5);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
// ── reconcileInstances ────────────────────────────────────────────────────────
|
|
260
|
+
describe("reconcileInstances", () => {
|
|
261
|
+
it("marks pending/active/merging instances failed when worktree does not exist", () => {
|
|
262
|
+
// These worktree paths do not exist on disk
|
|
263
|
+
makeInstance("recon-pending"); // status: pending
|
|
264
|
+
makeInstance("recon-active", "test-squad", "active"); // status: active
|
|
265
|
+
makeInstance("recon-merging", "test-squad", "merging"); // status: merging
|
|
266
|
+
const cleaned = reconcileInstances();
|
|
267
|
+
assert.equal(cleaned, 3);
|
|
268
|
+
assert.equal(getInstance("recon-pending").status, "failed");
|
|
269
|
+
assert.equal(getInstance("recon-active").status, "failed");
|
|
270
|
+
assert.equal(getInstance("recon-merging").status, "failed");
|
|
271
|
+
});
|
|
272
|
+
it("does not touch already-terminal instances", () => {
|
|
273
|
+
makeInstance("recon-done", "test-squad", "done");
|
|
274
|
+
makeInstance("recon-failed", "test-squad", "failed");
|
|
275
|
+
const cleaned = reconcileInstances();
|
|
276
|
+
assert.equal(cleaned, 0);
|
|
277
|
+
});
|
|
278
|
+
it("returns 0 when all non-terminal instances have valid worktrees", () => {
|
|
279
|
+
// Use a path that actually exists (tmpDir was created above)
|
|
280
|
+
createInstance({
|
|
281
|
+
id: "recon-exists",
|
|
282
|
+
masterSquadSlug: "test-squad",
|
|
283
|
+
worktreePath: tmpDir, // this path exists on disk
|
|
284
|
+
branchName: "test-squad/instance/recon-exists",
|
|
285
|
+
});
|
|
286
|
+
const cleaned = reconcileInstances();
|
|
287
|
+
assert.equal(cleaned, 0);
|
|
288
|
+
assert.equal(getInstance("recon-exists").status, "pending");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
//# sourceMappingURL=instances.test.js.map
|
package/dist/store/tasks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
|
-
export function createTask(taskId, agentSlug, description, originChannel) {
|
|
2
|
+
export function createTask(taskId, agentSlug, description, originChannel, instanceId) {
|
|
3
3
|
const db = getDb();
|
|
4
|
-
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, origin_channel) VALUES (?, ?, ?, ?)").run(taskId, agentSlug, description, originChannel ?? null);
|
|
4
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, origin_channel, instance_id) VALUES (?, ?, ?, ?, ?)").run(taskId, agentSlug, description, originChannel ?? null, instanceId ?? null);
|
|
5
5
|
return getTask(taskId);
|
|
6
6
|
}
|
|
7
7
|
export function getTask(taskId) {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/store/tasks.ts — agent task 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 { createTask, getTask, getActiveTasks, completeTask, failTask, cancelTask, listRecentTasks } from "./tasks.js";
|
|
14
|
+
// ── DB isolation ─────────────────────────────────────────────────────────────
|
|
15
|
+
let tmpDir;
|
|
16
|
+
before(() => {
|
|
17
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-tasks-test-"));
|
|
18
|
+
setDbPathForTests(join(tmpDir, "io.db"));
|
|
19
|
+
});
|
|
20
|
+
after(() => {
|
|
21
|
+
closeDb();
|
|
22
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
getDb().prepare("DELETE FROM agent_tasks").run();
|
|
26
|
+
});
|
|
27
|
+
// ── createTask / getTask ──────────────────────────────────────────────────────
|
|
28
|
+
describe("createTask", () => {
|
|
29
|
+
it("creates a task with correct base fields", () => {
|
|
30
|
+
const task = createTask("t-base", "agent-1", "Do something");
|
|
31
|
+
assert.equal(task.task_id, "t-base");
|
|
32
|
+
assert.equal(task.agent_slug, "agent-1");
|
|
33
|
+
assert.equal(task.description, "Do something");
|
|
34
|
+
assert.equal(task.status, "running");
|
|
35
|
+
assert.equal(task.result, null);
|
|
36
|
+
assert.equal(task.origin_channel, null);
|
|
37
|
+
assert.ok(task.started_at);
|
|
38
|
+
assert.equal(task.completed_at, null);
|
|
39
|
+
});
|
|
40
|
+
it("stores null instance_id when not provided", () => {
|
|
41
|
+
const task = createTask("t-no-instance", "agent-1", "Do something");
|
|
42
|
+
assert.equal(task.instance_id, null);
|
|
43
|
+
const fetched = getTask("t-no-instance");
|
|
44
|
+
assert.equal(fetched?.instance_id, null);
|
|
45
|
+
});
|
|
46
|
+
it("stores instance_id when provided", () => {
|
|
47
|
+
const task = createTask("t-with-instance", "agent-1", "Do something", undefined, "test-squad--issue-42");
|
|
48
|
+
assert.equal(task.instance_id, "test-squad--issue-42");
|
|
49
|
+
});
|
|
50
|
+
it("stores origin_channel when provided", () => {
|
|
51
|
+
const task = createTask("t-channel", "agent-1", "Task", "telegram");
|
|
52
|
+
assert.equal(task.origin_channel, "telegram");
|
|
53
|
+
});
|
|
54
|
+
it("stores both origin_channel and instance_id together", () => {
|
|
55
|
+
const task = createTask("t-both", "agent-1", "Task", "telegram", "squad--issue-5");
|
|
56
|
+
assert.equal(task.origin_channel, "telegram");
|
|
57
|
+
assert.equal(task.instance_id, "squad--issue-5");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("getTask", () => {
|
|
61
|
+
it("returns undefined for non-existent task_id", () => {
|
|
62
|
+
assert.equal(getTask("no-such-task"), undefined);
|
|
63
|
+
});
|
|
64
|
+
it("returns instance_id field correctly from DB", () => {
|
|
65
|
+
createTask("t-fetch", "agent-2", "Fetch test", undefined, "squad--issue-99");
|
|
66
|
+
const fetched = getTask("t-fetch");
|
|
67
|
+
assert.ok(fetched);
|
|
68
|
+
assert.equal(fetched.instance_id, "squad--issue-99");
|
|
69
|
+
assert.equal(fetched.task_id, "t-fetch");
|
|
70
|
+
});
|
|
71
|
+
it("returns null instance_id for tasks created without one", () => {
|
|
72
|
+
createTask("t-fetch-null", "agent-2", "No instance");
|
|
73
|
+
const fetched = getTask("t-fetch-null");
|
|
74
|
+
assert.equal(fetched?.instance_id, null);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ── getActiveTasks ────────────────────────────────────────────────────────────
|
|
78
|
+
describe("getActiveTasks", () => {
|
|
79
|
+
it("returns only running tasks", () => {
|
|
80
|
+
createTask("t-run1", "agent-1", "Running 1");
|
|
81
|
+
createTask("t-run2", "agent-1", "Running 2");
|
|
82
|
+
const t = createTask("t-done", "agent-1", "Done task");
|
|
83
|
+
completeTask(t.task_id, "finished");
|
|
84
|
+
const active = getActiveTasks();
|
|
85
|
+
assert.equal(active.length, 2);
|
|
86
|
+
assert.ok(active.every((t) => t.status === "running"));
|
|
87
|
+
});
|
|
88
|
+
it("returns empty array when no running tasks", () => {
|
|
89
|
+
assert.deepEqual(getActiveTasks(), []);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── completeTask ──────────────────────────────────────────────────────────────
|
|
93
|
+
describe("completeTask", () => {
|
|
94
|
+
it("sets status to done and records result and completed_at", () => {
|
|
95
|
+
createTask("t-complete", "agent-1", "Task to complete");
|
|
96
|
+
completeTask("t-complete", "all done");
|
|
97
|
+
const t = getTask("t-complete");
|
|
98
|
+
assert.equal(t?.status, "done");
|
|
99
|
+
assert.equal(t?.result, "all done");
|
|
100
|
+
assert.ok(t?.completed_at);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// ── failTask ──────────────────────────────────────────────────────────────────
|
|
104
|
+
describe("failTask", () => {
|
|
105
|
+
it("sets status to failed and records error and completed_at", () => {
|
|
106
|
+
createTask("t-fail", "agent-1", "Task to fail");
|
|
107
|
+
failTask("t-fail", "something went wrong");
|
|
108
|
+
const t = getTask("t-fail");
|
|
109
|
+
assert.equal(t?.status, "failed");
|
|
110
|
+
assert.equal(t?.result, "something went wrong");
|
|
111
|
+
assert.ok(t?.completed_at);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
// ── cancelTask ────────────────────────────────────────────────────────────────
|
|
115
|
+
describe("cancelTask", () => {
|
|
116
|
+
it("cancels a running task with default reason", () => {
|
|
117
|
+
createTask("t-cancel", "agent-1", "Task to cancel");
|
|
118
|
+
cancelTask("t-cancel");
|
|
119
|
+
const t = getTask("t-cancel");
|
|
120
|
+
assert.equal(t?.status, "cancelled");
|
|
121
|
+
assert.ok(t?.result?.includes("Cancelled"));
|
|
122
|
+
});
|
|
123
|
+
it("does not cancel an already-completed task", () => {
|
|
124
|
+
createTask("t-cancel-done", "agent-1", "Already done");
|
|
125
|
+
completeTask("t-cancel-done", "done");
|
|
126
|
+
cancelTask("t-cancel-done");
|
|
127
|
+
// status should remain 'done'
|
|
128
|
+
assert.equal(getTask("t-cancel-done")?.status, "done");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ── listRecentTasks ───────────────────────────────────────────────────────────
|
|
132
|
+
describe("listRecentTasks", () => {
|
|
133
|
+
it("returns tasks newest first", () => {
|
|
134
|
+
createTask("t-list-1", "agent-1", "First");
|
|
135
|
+
createTask("t-list-2", "agent-1", "Second");
|
|
136
|
+
const tasks = listRecentTasks();
|
|
137
|
+
assert.equal(tasks.length, 2);
|
|
138
|
+
// Both should be present; newest (by insert order / task_id sort) first
|
|
139
|
+
assert.ok(tasks.some((t) => t.task_id === "t-list-1"));
|
|
140
|
+
assert.ok(tasks.some((t) => t.task_id === "t-list-2"));
|
|
141
|
+
});
|
|
142
|
+
it("respects limit", () => {
|
|
143
|
+
for (let i = 0; i < 5; i++) {
|
|
144
|
+
createTask(`t-limit-${i}`, "agent-1", `Task ${i}`);
|
|
145
|
+
}
|
|
146
|
+
const tasks = listRecentTasks(3);
|
|
147
|
+
assert.equal(tasks.length, 3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
//# sourceMappingURL=tasks.test.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { existsSync, rmSync, mkdirSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Create a git worktree for a squad instance.
|
|
6
|
+
* @param projectPath - The main project directory (must be a git repo)
|
|
7
|
+
* @param instanceId - Used to name the worktree subdirectory
|
|
8
|
+
* @param branchName - The branch to create for this worktree
|
|
9
|
+
* @param baseBranch - The branch to base the worktree on (default: "main")
|
|
10
|
+
* @returns The absolute path to the created worktree
|
|
11
|
+
*/
|
|
12
|
+
export function createWorktree(projectPath, instanceId, branchName, baseBranch = "main") {
|
|
13
|
+
const worktreeDir = path.join(projectPath, ".io-worktrees", instanceId);
|
|
14
|
+
const parentDir = path.join(projectPath, ".io-worktrees");
|
|
15
|
+
if (!existsSync(parentDir)) {
|
|
16
|
+
mkdirSync(parentDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
// Fetch latest to ensure base branch is up to date
|
|
19
|
+
try {
|
|
20
|
+
execSync(`git fetch origin ${baseBranch}`, { cwd: projectPath, stdio: "pipe" });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Best effort — might be offline or no remote
|
|
24
|
+
}
|
|
25
|
+
// Create the worktree with a new branch from the base
|
|
26
|
+
execSync(`git worktree add "${worktreeDir}" -b "${branchName}" "origin/${baseBranch}"`, { cwd: projectPath, stdio: "pipe" });
|
|
27
|
+
return worktreeDir;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Remove a git worktree. Falls back to rm -rf + prune if git remove fails.
|
|
31
|
+
*/
|
|
32
|
+
export function removeWorktree(projectPath, worktreePath) {
|
|
33
|
+
try {
|
|
34
|
+
execSync(`git worktree remove "${worktreePath}" --force`, {
|
|
35
|
+
cwd: projectPath,
|
|
36
|
+
stdio: "pipe",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fallback: force remove directory and prune
|
|
41
|
+
if (existsSync(worktreePath)) {
|
|
42
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
execSync("git worktree prune", { cwd: projectPath, stdio: "pipe" });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// best effort
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* List all worktrees for a project.
|
|
54
|
+
*/
|
|
55
|
+
export function listWorktrees(projectPath) {
|
|
56
|
+
try {
|
|
57
|
+
const output = execSync("git worktree list --porcelain", {
|
|
58
|
+
cwd: projectPath,
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
});
|
|
61
|
+
const entries = [];
|
|
62
|
+
let currentPath = "";
|
|
63
|
+
for (const line of output.split("\n")) {
|
|
64
|
+
if (line.startsWith("worktree ")) {
|
|
65
|
+
currentPath = line.slice("worktree ".length);
|
|
66
|
+
}
|
|
67
|
+
else if (line.startsWith("branch ")) {
|
|
68
|
+
entries.push({ path: currentPath, branch: line.slice("branch refs/heads/".length) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check if a worktree path exists on disk.
|
|
79
|
+
*/
|
|
80
|
+
export function worktreeExists(worktreePath) {
|
|
81
|
+
return existsSync(worktreePath);
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=worktrees.js.map
|