heyio 0.32.0 → 0.33.1
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/mcp.test.js +285 -0
- package/dist/copilot/agents.js +52 -20
- package/dist/copilot/orchestrator.js +8 -5
- package/dist/daemon.js +2 -2
- package/dist/mcp/config.js +12 -4
- package/dist/store/instances.js +9 -1
- package/dist/store/instances.test.js +26 -7
- package/dist/telegram/bot.js +175 -0
- package/dist/wiki/fs.js +11 -0
- package/dist/wiki/wiki-squad.test.js +54 -0
- package/package.json +1 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for MCP API endpoints (#279).
|
|
3
|
+
*
|
|
4
|
+
* Strategy: spin up a minimal Express server that mounts the same MCP route
|
|
5
|
+
* handlers as src/api/server.ts, but pointed at a temp config file via
|
|
6
|
+
* setMcpConfigPathForTests(). Tests make real HTTP requests using node:http.
|
|
7
|
+
* The /mcp/reload endpoint uses an injectable reload fn to avoid pulling in
|
|
8
|
+
* the full orchestrator (which needs DB + Copilot client).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, before, after, beforeEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import express from "express";
|
|
17
|
+
import { setMcpConfigPathForTests, resetMcpConfigPath, loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
function req(method, port, path, body) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
22
|
+
const options = {
|
|
23
|
+
hostname: "127.0.0.1",
|
|
24
|
+
port,
|
|
25
|
+
path,
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const r = http.request(options, (res) => {
|
|
32
|
+
let data = "";
|
|
33
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
34
|
+
res.on("end", () => {
|
|
35
|
+
try {
|
|
36
|
+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
resolve({ status: res.statusCode ?? 0, body: data });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
r.on("error", reject);
|
|
44
|
+
if (payload)
|
|
45
|
+
r.write(payload);
|
|
46
|
+
r.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// ── Test server setup ─────────────────────────────────────────────────────────
|
|
50
|
+
let server;
|
|
51
|
+
let port;
|
|
52
|
+
let tmpDir;
|
|
53
|
+
let configPath;
|
|
54
|
+
function buildMcpApp(reloadFn = async () => { }) {
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.get("/api/mcp/servers", (_req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const config = loadMcpConfig();
|
|
60
|
+
res.json({ servers: config.servers });
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
app.post("/api/mcp/servers", (req, res) => {
|
|
67
|
+
const { name, command, args, url, env } = req.body;
|
|
68
|
+
if (!name) {
|
|
69
|
+
res.status(400).json({ error: "name is required" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!command && !url) {
|
|
73
|
+
res.status(400).json({ error: "command or url is required" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const config = loadMcpConfig();
|
|
78
|
+
if (config.servers.find(s => s.name === name)) {
|
|
79
|
+
res.status(409).json({ error: "server already exists" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
config.servers.push({ name, command, args, url, env, enabled: true });
|
|
83
|
+
saveMcpConfig(config);
|
|
84
|
+
res.status(201).json({ ok: true });
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
app.delete("/api/mcp/servers/:name", (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const config = loadMcpConfig();
|
|
93
|
+
const idx = config.servers.findIndex(s => s.name === req.params.name);
|
|
94
|
+
if (idx === -1) {
|
|
95
|
+
res.status(404).json({ error: "server not found" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
config.servers.splice(idx, 1);
|
|
99
|
+
saveMcpConfig(config);
|
|
100
|
+
res.json({ ok: true });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
app.patch("/api/mcp/servers/:name/toggle", (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const config = loadMcpConfig();
|
|
109
|
+
const srv = config.servers.find(s => s.name === req.params.name);
|
|
110
|
+
if (!srv) {
|
|
111
|
+
res.status(404).json({ error: "server not found" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
srv.enabled = srv.enabled === false ? true : false;
|
|
115
|
+
saveMcpConfig(config);
|
|
116
|
+
res.json({ ok: true, enabled: srv.enabled });
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
app.post("/api/mcp/reload", async (_req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
await reloadFn();
|
|
125
|
+
res.json({ ok: true });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
res.status(500).json({ error: err instanceof Error ? err.message : "reload failed" });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return app;
|
|
132
|
+
}
|
|
133
|
+
before(async () => {
|
|
134
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-api-test-"));
|
|
135
|
+
configPath = join(tmpDir, "mcp.json");
|
|
136
|
+
setMcpConfigPathForTests(configPath);
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
server = buildMcpApp().listen(0, "127.0.0.1", () => {
|
|
139
|
+
port = server.address().port;
|
|
140
|
+
resolve();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
after(async () => {
|
|
145
|
+
await new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()));
|
|
146
|
+
resetMcpConfigPath();
|
|
147
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
148
|
+
});
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
// Reset config file between tests
|
|
151
|
+
saveMcpConfig({ servers: [] });
|
|
152
|
+
});
|
|
153
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
154
|
+
describe("GET /api/mcp/servers", () => {
|
|
155
|
+
it("returns empty array when no servers configured", async () => {
|
|
156
|
+
const res = await req("GET", port, "/api/mcp/servers");
|
|
157
|
+
assert.equal(res.status, 200);
|
|
158
|
+
assert.deepEqual(res.body.servers, []);
|
|
159
|
+
});
|
|
160
|
+
it("returns configured servers", async () => {
|
|
161
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
|
|
162
|
+
const res = await req("GET", port, "/api/mcp/servers");
|
|
163
|
+
assert.equal(res.status, 200);
|
|
164
|
+
const { servers } = res.body;
|
|
165
|
+
assert.equal(servers.length, 1);
|
|
166
|
+
assert.equal(servers[0].name, "figma");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("POST /api/mcp/servers", () => {
|
|
170
|
+
it("creates a stdio server successfully", async () => {
|
|
171
|
+
const res = await req("POST", port, "/api/mcp/servers", {
|
|
172
|
+
name: "github",
|
|
173
|
+
command: "npx",
|
|
174
|
+
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
175
|
+
});
|
|
176
|
+
assert.equal(res.status, 201);
|
|
177
|
+
assert.equal(res.body.ok, true);
|
|
178
|
+
const config = loadMcpConfig();
|
|
179
|
+
assert.equal(config.servers.length, 1);
|
|
180
|
+
assert.equal(config.servers[0].name, "github");
|
|
181
|
+
assert.equal(config.servers[0].command, "npx");
|
|
182
|
+
assert.deepEqual(config.servers[0].args, ["-y", "@modelcontextprotocol/server-github"]);
|
|
183
|
+
assert.equal(config.servers[0].enabled, true);
|
|
184
|
+
});
|
|
185
|
+
it("creates an SSE server successfully", async () => {
|
|
186
|
+
const res = await req("POST", port, "/api/mcp/servers", {
|
|
187
|
+
name: "postgres",
|
|
188
|
+
url: "http://localhost:3001/sse",
|
|
189
|
+
});
|
|
190
|
+
assert.equal(res.status, 201);
|
|
191
|
+
const config = loadMcpConfig();
|
|
192
|
+
assert.equal(config.servers[0].url, "http://localhost:3001/sse");
|
|
193
|
+
});
|
|
194
|
+
it("returns 400 when name is missing", async () => {
|
|
195
|
+
const res = await req("POST", port, "/api/mcp/servers", { command: "npx" });
|
|
196
|
+
assert.equal(res.status, 400);
|
|
197
|
+
assert.ok(res.body.error.includes("name"));
|
|
198
|
+
});
|
|
199
|
+
it("returns 400 when both command and url are missing", async () => {
|
|
200
|
+
const res = await req("POST", port, "/api/mcp/servers", { name: "bad-server" });
|
|
201
|
+
assert.equal(res.status, 400);
|
|
202
|
+
assert.ok(res.body.error.includes("command"));
|
|
203
|
+
});
|
|
204
|
+
it("returns 409 for duplicate server name", async () => {
|
|
205
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx" }] });
|
|
206
|
+
const res = await req("POST", port, "/api/mcp/servers", { name: "figma", command: "npx" });
|
|
207
|
+
assert.equal(res.status, 409);
|
|
208
|
+
assert.ok(res.body.error.includes("already exists"));
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe("DELETE /api/mcp/servers/:name", () => {
|
|
212
|
+
it("removes an existing server", async () => {
|
|
213
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx" }, { name: "postgres", url: "http://localhost/sse" }] });
|
|
214
|
+
const res = await req("DELETE", port, "/api/mcp/servers/figma");
|
|
215
|
+
assert.equal(res.status, 200);
|
|
216
|
+
assert.equal(res.body.ok, true);
|
|
217
|
+
const config = loadMcpConfig();
|
|
218
|
+
assert.equal(config.servers.length, 1);
|
|
219
|
+
assert.equal(config.servers[0].name, "postgres");
|
|
220
|
+
});
|
|
221
|
+
it("returns 404 for unknown server", async () => {
|
|
222
|
+
const res = await req("DELETE", port, "/api/mcp/servers/nonexistent");
|
|
223
|
+
assert.equal(res.status, 404);
|
|
224
|
+
assert.ok(res.body.error.includes("not found"));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe("PATCH /api/mcp/servers/:name/toggle", () => {
|
|
228
|
+
it("disables an enabled server", async () => {
|
|
229
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
|
|
230
|
+
const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
|
|
231
|
+
assert.equal(res.status, 200);
|
|
232
|
+
assert.equal(res.body.enabled, false);
|
|
233
|
+
assert.equal(loadMcpConfig().servers[0].enabled, false);
|
|
234
|
+
});
|
|
235
|
+
it("enables a disabled server", async () => {
|
|
236
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: false }] });
|
|
237
|
+
const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
assert.equal(res.body.enabled, true);
|
|
240
|
+
});
|
|
241
|
+
it("returns 404 for unknown server", async () => {
|
|
242
|
+
const res = await req("PATCH", port, "/api/mcp/servers/nonexistent/toggle");
|
|
243
|
+
assert.equal(res.status, 404);
|
|
244
|
+
assert.ok(res.body.error.includes("not found"));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe("POST /api/mcp/reload", () => {
|
|
248
|
+
it("calls reload function and returns ok", async () => {
|
|
249
|
+
let reloadCalled = false;
|
|
250
|
+
const app = buildMcpApp(async () => { reloadCalled = true; });
|
|
251
|
+
await new Promise((resolve, reject) => {
|
|
252
|
+
const s = app.listen(0, "127.0.0.1", async () => {
|
|
253
|
+
const p = s.address().port;
|
|
254
|
+
try {
|
|
255
|
+
const res = await req("POST", p, "/api/mcp/reload");
|
|
256
|
+
assert.equal(res.status, 200);
|
|
257
|
+
assert.equal(res.body.ok, true);
|
|
258
|
+
assert.ok(reloadCalled, "reload function should have been called");
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
s.close(() => resolve());
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
s.on("error", reject);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
it("returns 500 when reload throws", async () => {
|
|
268
|
+
const app = buildMcpApp(async () => { throw new Error("init failed"); });
|
|
269
|
+
await new Promise((resolve, reject) => {
|
|
270
|
+
const s = app.listen(0, "127.0.0.1", async () => {
|
|
271
|
+
const p = s.address().port;
|
|
272
|
+
try {
|
|
273
|
+
const res = await req("POST", p, "/api/mcp/reload");
|
|
274
|
+
assert.equal(res.status, 500);
|
|
275
|
+
assert.ok(res.body.error.includes("init failed"));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
s.close(() => resolve());
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
s.on("error", reject);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
//# sourceMappingURL=mcp.test.js.map
|
package/dist/copilot/agents.js
CHANGED
|
@@ -17,6 +17,7 @@ import { removeWorktree } from "../store/worktrees.js";
|
|
|
17
17
|
import { createFeedEntry } from "../store/feed.js";
|
|
18
18
|
import { SESSIONS_DIR } from "../paths.js";
|
|
19
19
|
import { getUniverse } from "./universes.js";
|
|
20
|
+
import { readSquadWikiPages } from "../wiki/fs.js";
|
|
20
21
|
// Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
|
|
21
22
|
const agentSessions = new Map();
|
|
22
23
|
const agentSessionModels = new Map();
|
|
@@ -417,6 +418,10 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
|
|
|
417
418
|
const squad = getSquad(squadSlug);
|
|
418
419
|
const client = await getClient();
|
|
419
420
|
const decisions = getDecisionsSummary(squadSlug);
|
|
421
|
+
const wikiPages = readSquadWikiPages(squadSlug);
|
|
422
|
+
const wikiSection = wikiPages.length > 0
|
|
423
|
+
? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
|
|
424
|
+
: "";
|
|
420
425
|
console.error(`[io] Agent ${agent.character_name}: using model "${model}" (agent tier: ${agentTier}, task tier: ${taskTier}, effective: ${effectiveTier})`);
|
|
421
426
|
const universeName = squad.universe
|
|
422
427
|
? getUniverse(squad.universe)?.name ?? squad.universe
|
|
@@ -479,7 +484,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
|
|
|
479
484
|
- **Path**: ${squad.project_path}
|
|
480
485
|
|
|
481
486
|
## Past Decisions
|
|
482
|
-
${decisions}${leadSection}
|
|
487
|
+
${decisions}${leadSection}${wikiSection}
|
|
483
488
|
|
|
484
489
|
## Repository Hygiene
|
|
485
490
|
Before you make ANY code changes, you MUST sync your working copy with the remote default branch and work from a fresh feature branch. This prevents the merge conflicts the team hit on PRs like #45.
|
|
@@ -537,6 +542,10 @@ async function getOrCreateSession(squadSlug, taskDescription) {
|
|
|
537
542
|
const squad = getSquad(squadSlug);
|
|
538
543
|
const client = await getClient();
|
|
539
544
|
const decisions = getDecisionsSummary(squadSlug);
|
|
545
|
+
const wikiPages = readSquadWikiPages(squadSlug);
|
|
546
|
+
const wikiSection = wikiPages.length > 0
|
|
547
|
+
? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
|
|
548
|
+
: "";
|
|
540
549
|
const agentTools = buildAgentTools(squadSlug);
|
|
541
550
|
const model = getModelForTask(taskDescription ?? "", squad.model);
|
|
542
551
|
const commonConfig = {
|
|
@@ -738,27 +747,50 @@ function buildAgentTools(squadSlug, isLead = false) {
|
|
|
738
747
|
updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
|
|
739
748
|
try {
|
|
740
749
|
const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
// long-running shell work between assistant messages.)
|
|
746
|
-
const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
|
|
747
|
-
idleMs: 10 * 60_000,
|
|
748
|
-
hardCapMs: 30 * 60_000,
|
|
749
|
-
onIdleTimeout: ({ lastEventType }) => {
|
|
750
|
-
console.error(`[io] Teammate ${teammateAgent.character_name} idle (last event: ${lastEventType ?? "none"}) — aborting.`);
|
|
751
|
-
},
|
|
750
|
+
recordTaskEvent(childTaskId, {
|
|
751
|
+
ts: Date.now(),
|
|
752
|
+
type: "task.start",
|
|
753
|
+
data: { taskId: childTaskId, agentKey: childAgentKey, description: task },
|
|
752
754
|
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
755
|
+
let unsubChild;
|
|
756
|
+
try {
|
|
757
|
+
unsubChild = session.on((event) => {
|
|
758
|
+
if (!STREAM_EVENT_TYPES.has(event.type))
|
|
759
|
+
return;
|
|
760
|
+
recordTaskEvent(childTaskId, {
|
|
761
|
+
ts: Date.now(),
|
|
762
|
+
type: event.type,
|
|
763
|
+
data: event.data ?? null,
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
const envelopedTask = buildTaskPromptEnvelope(squadSlug, task);
|
|
767
|
+
// Idle-reset timeout: 10min between progress events, 30min
|
|
768
|
+
// hard cap. (Issue #53 — replaces #51's 30min wall-clock cap
|
|
769
|
+
// that still killed agents mid-tool-call when they had
|
|
770
|
+
// long-running shell work between assistant messages.)
|
|
771
|
+
const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
|
|
772
|
+
idleMs: 10 * 60_000,
|
|
773
|
+
hardCapMs: 30 * 60_000,
|
|
774
|
+
onIdleTimeout: ({ lastEventType }) => {
|
|
775
|
+
console.error(`[io] Teammate ${teammateAgent.character_name} idle (last event: ${lastEventType ?? "none"}) — aborting.`);
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
const result = sendResult.content || "(teammate returned no output)";
|
|
779
|
+
updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
|
|
780
|
+
if (sendResult.timedOut) {
|
|
781
|
+
const stamped = `[teammate timed out — ${sendResult.timeoutReason === "idle" ? "idle reset" : "hard cap"}; last event: ${sendResult.lastEventType ?? "none"}]\n\n${result}`;
|
|
782
|
+
failTask(childTaskId, stamped);
|
|
783
|
+
return stamped;
|
|
784
|
+
}
|
|
785
|
+
completeTask(childTaskId, result);
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
finally {
|
|
789
|
+
try {
|
|
790
|
+
unsubChild?.();
|
|
791
|
+
}
|
|
792
|
+
catch { /* best-effort cleanup */ }
|
|
759
793
|
}
|
|
760
|
-
completeTask(childTaskId, result);
|
|
761
|
-
return result;
|
|
762
794
|
}
|
|
763
795
|
catch (err) {
|
|
764
796
|
updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
|
|
@@ -347,7 +347,7 @@ function invalidateSession() {
|
|
|
347
347
|
// ---------------------------------------------------------------------------
|
|
348
348
|
// Message execution
|
|
349
349
|
// ---------------------------------------------------------------------------
|
|
350
|
-
async function executeOnSession(prompt, callback) {
|
|
350
|
+
async function executeOnSession(prompt, callback, attachments) {
|
|
351
351
|
const session = await ensureOrchestratorSession();
|
|
352
352
|
let accumulated = "";
|
|
353
353
|
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
@@ -356,7 +356,10 @@ async function executeOnSession(prompt, callback) {
|
|
|
356
356
|
callback(delta, false);
|
|
357
357
|
});
|
|
358
358
|
try {
|
|
359
|
-
const
|
|
359
|
+
const sendPayload = { prompt };
|
|
360
|
+
if (attachments && attachments.length > 0)
|
|
361
|
+
sendPayload.attachments = attachments;
|
|
362
|
+
const result = await session.sendAndWait(sendPayload, SEND_TIMEOUT_MS);
|
|
360
363
|
unsubDelta();
|
|
361
364
|
const finalText = result?.data.content ?? accumulated;
|
|
362
365
|
callback("", true);
|
|
@@ -412,7 +415,7 @@ async function processQueue() {
|
|
|
412
415
|
let lastError;
|
|
413
416
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
414
417
|
try {
|
|
415
|
-
const response = await executeOnSession(taggedPrompt, msg.callback);
|
|
418
|
+
const response = await executeOnSession(taggedPrompt, msg.callback, msg.attachments);
|
|
416
419
|
logConversation("assistant", response, sourceLabel(msg.source));
|
|
417
420
|
msg.resolve();
|
|
418
421
|
lastError = undefined;
|
|
@@ -507,10 +510,10 @@ export async function initOrchestrator(copilotClient) {
|
|
|
507
510
|
console.error("[io] Eager session creation failed (will retry on first message):", err instanceof Error ? err.message : err);
|
|
508
511
|
}
|
|
509
512
|
}
|
|
510
|
-
export async function sendToOrchestrator(prompt, source, callback) {
|
|
513
|
+
export async function sendToOrchestrator(prompt, source, callback, attachments) {
|
|
511
514
|
logConversation("user", prompt, sourceLabel(source));
|
|
512
515
|
return new Promise((resolve, reject) => {
|
|
513
|
-
messageQueue.push({ prompt, source, callback, resolve, reject });
|
|
516
|
+
messageQueue.push({ prompt, source, callback, attachments, resolve, reject });
|
|
514
517
|
processQueue();
|
|
515
518
|
});
|
|
516
519
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -110,8 +110,8 @@ export async function startDaemon() {
|
|
|
110
110
|
await startApiServer();
|
|
111
111
|
// Wire up Telegram handler
|
|
112
112
|
if (config.telegramEnabled) {
|
|
113
|
-
setTelegramHandler(async (text, chatId, messageId, callback) => {
|
|
114
|
-
await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback);
|
|
113
|
+
setTelegramHandler(async (text, chatId, messageId, callback, attachments) => {
|
|
114
|
+
await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback, attachments);
|
|
115
115
|
});
|
|
116
116
|
createBot();
|
|
117
117
|
await startBot();
|
package/dist/mcp/config.js
CHANGED
|
@@ -3,12 +3,20 @@ import { dirname } from "path";
|
|
|
3
3
|
import { IO_HOME } from "../paths.js";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
export const MCP_CONFIG_PATH = join(IO_HOME, "mcp.json");
|
|
6
|
+
// Mutable override for tests — mirrors the setDbPathForTests pattern.
|
|
7
|
+
let _configPath = MCP_CONFIG_PATH;
|
|
8
|
+
export function setMcpConfigPathForTests(path) {
|
|
9
|
+
_configPath = path;
|
|
10
|
+
}
|
|
11
|
+
export function resetMcpConfigPath() {
|
|
12
|
+
_configPath = MCP_CONFIG_PATH;
|
|
13
|
+
}
|
|
6
14
|
export function loadMcpConfig() {
|
|
7
|
-
if (!existsSync(
|
|
15
|
+
if (!existsSync(_configPath)) {
|
|
8
16
|
return { servers: [] };
|
|
9
17
|
}
|
|
10
18
|
try {
|
|
11
|
-
const raw = readFileSync(
|
|
19
|
+
const raw = readFileSync(_configPath, "utf-8");
|
|
12
20
|
const parsed = JSON.parse(raw);
|
|
13
21
|
if (!parsed.servers || !Array.isArray(parsed.servers)) {
|
|
14
22
|
return { servers: [] };
|
|
@@ -20,10 +28,10 @@ export function loadMcpConfig() {
|
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
export function saveMcpConfig(config) {
|
|
23
|
-
const dir = dirname(
|
|
31
|
+
const dir = dirname(_configPath);
|
|
24
32
|
if (!existsSync(dir)) {
|
|
25
33
|
mkdirSync(dir, { recursive: true });
|
|
26
34
|
}
|
|
27
|
-
writeFileSync(
|
|
35
|
+
writeFileSync(_configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
28
36
|
}
|
|
29
37
|
//# sourceMappingURL=config.js.map
|
package/dist/store/instances.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
2
|
import { getDecisions } from "./squads.js";
|
|
3
3
|
import { worktreeExists } from "./worktrees.js";
|
|
4
|
+
import { readSquadWikiPages } from "../wiki/fs.js";
|
|
4
5
|
export function ensureInstanceTables() {
|
|
5
6
|
const db = getDb();
|
|
6
7
|
db.exec(`
|
|
@@ -108,7 +109,14 @@ export function deleteInstance(id) {
|
|
|
108
109
|
*/
|
|
109
110
|
export function buildContextSnapshot(masterSquadSlug, limit = 30) {
|
|
110
111
|
const decisions = getDecisions(masterSquadSlug, limit);
|
|
111
|
-
|
|
112
|
+
const wikiPages = readSquadWikiPages(masterSquadSlug);
|
|
113
|
+
const snapshot = {
|
|
114
|
+
decisions: decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })),
|
|
115
|
+
};
|
|
116
|
+
if (wikiPages.length > 0) {
|
|
117
|
+
snapshot.wiki = wikiPages.map(p => ({ path: p.path, content: p.content }));
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(snapshot);
|
|
112
120
|
}
|
|
113
121
|
/**
|
|
114
122
|
* Reconcile instances on startup: detect orphaned worktrees and mark stale active instances.
|
|
@@ -234,18 +234,19 @@ describe("deleteInstance", () => {
|
|
|
234
234
|
});
|
|
235
235
|
// ── buildContextSnapshot ──────────────────────────────────────────────────────
|
|
236
236
|
describe("buildContextSnapshot", () => {
|
|
237
|
-
it("returns a JSON
|
|
237
|
+
it("returns a JSON object with decisions array", () => {
|
|
238
238
|
logDecision("test-squad", "use TypeScript everywhere", "consistency");
|
|
239
239
|
logDecision("test-squad", "prefer functional style");
|
|
240
240
|
const snapshot = buildContextSnapshot("test-squad");
|
|
241
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"));
|
|
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
245
|
});
|
|
246
|
-
it("returns
|
|
246
|
+
it("returns empty decisions array for a squad with no decisions", () => {
|
|
247
247
|
const snapshot = buildContextSnapshot("test-squad");
|
|
248
|
-
|
|
248
|
+
const parsed = JSON.parse(snapshot);
|
|
249
|
+
assert.deepEqual(parsed.decisions, []);
|
|
249
250
|
});
|
|
250
251
|
it("respects the limit parameter", () => {
|
|
251
252
|
for (let i = 0; i < 10; i++) {
|
|
@@ -253,7 +254,25 @@ describe("buildContextSnapshot", () => {
|
|
|
253
254
|
}
|
|
254
255
|
const snapshot = buildContextSnapshot("test-squad", 5);
|
|
255
256
|
const parsed = JSON.parse(snapshot);
|
|
256
|
-
assert.equal(parsed.length, 5);
|
|
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
|
+
}
|
|
257
276
|
});
|
|
258
277
|
});
|
|
259
278
|
// ── reconcileInstances ────────────────────────────────────────────────────────
|
package/dist/telegram/bot.js
CHANGED
|
@@ -2,8 +2,17 @@ import { Bot } from "grammy";
|
|
|
2
2
|
import { config } from "../config.js";
|
|
3
3
|
const TELEGRAM_MAX_LENGTH = 4096;
|
|
4
4
|
const EDIT_DEBOUNCE_MS = 500;
|
|
5
|
+
const FILE_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; // 5MB
|
|
5
6
|
let bot;
|
|
6
7
|
let messageHandler;
|
|
8
|
+
async function downloadTelegramFile(botInstance, fileId) {
|
|
9
|
+
const file = await botInstance.api.getFile(fileId);
|
|
10
|
+
const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${file.file_path}`;
|
|
11
|
+
const response = await fetch(url);
|
|
12
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
13
|
+
const mimeType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
14
|
+
return { data: buffer.toString("base64"), mimeType, size: buffer.length };
|
|
15
|
+
}
|
|
7
16
|
export function setMessageHandler(handler) {
|
|
8
17
|
messageHandler = handler;
|
|
9
18
|
}
|
|
@@ -82,6 +91,172 @@ export function createBot() {
|
|
|
82
91
|
await editReply("An error occurred while processing your message.");
|
|
83
92
|
}
|
|
84
93
|
});
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Photo handler
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
bot.on("message:photo", async (ctx) => {
|
|
98
|
+
const userId = ctx.from?.id;
|
|
99
|
+
if (config.authorizedUserId && userId !== config.authorizedUserId)
|
|
100
|
+
return;
|
|
101
|
+
if (!messageHandler || !bot) {
|
|
102
|
+
console.error("[io] No message handler registered");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Telegram sends an array of sizes — last element is the highest resolution
|
|
106
|
+
const photos = ctx.message.photo;
|
|
107
|
+
const photo = photos[photos.length - 1];
|
|
108
|
+
const chatId = ctx.chat.id;
|
|
109
|
+
const messageId = ctx.message.message_id;
|
|
110
|
+
const caption = ctx.message.caption ?? "";
|
|
111
|
+
await ctx.replyWithChatAction("typing");
|
|
112
|
+
const ack = await ctx.reply("📎 Processing attachment…");
|
|
113
|
+
try {
|
|
114
|
+
const { data, mimeType, size } = await downloadTelegramFile(bot, photo.file_id);
|
|
115
|
+
if (size > FILE_SIZE_LIMIT_BYTES) {
|
|
116
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const attachment = { type: "blob", data, mimeType, displayName: "photo.jpg" };
|
|
120
|
+
const placeholder = await ctx.reply("…");
|
|
121
|
+
let accumulated = "";
|
|
122
|
+
let lastEditTime = 0;
|
|
123
|
+
let pendingEdit;
|
|
124
|
+
const editReply = async (content) => {
|
|
125
|
+
try {
|
|
126
|
+
const truncated = content.length > TELEGRAM_MAX_LENGTH
|
|
127
|
+
? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
|
|
128
|
+
: content;
|
|
129
|
+
await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
if (!message.includes("message is not modified")) {
|
|
134
|
+
console.error("[io] Failed to edit message:", message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
await ctx.api.deleteMessage(chatId, ack.message_id);
|
|
139
|
+
await messageHandler(caption, chatId, messageId, (chunk, done) => {
|
|
140
|
+
accumulated += chunk;
|
|
141
|
+
if (done) {
|
|
142
|
+
if (pendingEdit) {
|
|
143
|
+
clearTimeout(pendingEdit);
|
|
144
|
+
pendingEdit = undefined;
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const timeSinceLastEdit = now - lastEditTime;
|
|
150
|
+
if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
|
|
151
|
+
lastEditTime = now;
|
|
152
|
+
void editReply(accumulated);
|
|
153
|
+
}
|
|
154
|
+
else if (!pendingEdit) {
|
|
155
|
+
pendingEdit = setTimeout(() => {
|
|
156
|
+
pendingEdit = undefined;
|
|
157
|
+
lastEditTime = Date.now();
|
|
158
|
+
void editReply(accumulated);
|
|
159
|
+
}, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
|
|
160
|
+
}
|
|
161
|
+
}, [attachment]);
|
|
162
|
+
if (pendingEdit)
|
|
163
|
+
clearTimeout(pendingEdit);
|
|
164
|
+
if (accumulated.length > 0)
|
|
165
|
+
await editReply(accumulated);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
+
console.error("[io] Error handling photo:", message);
|
|
170
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Document handler
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
bot.on("message:document", async (ctx) => {
|
|
177
|
+
const userId = ctx.from?.id;
|
|
178
|
+
if (config.authorizedUserId && userId !== config.authorizedUserId)
|
|
179
|
+
return;
|
|
180
|
+
if (!messageHandler || !bot) {
|
|
181
|
+
console.error("[io] No message handler registered");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const doc = ctx.message.document;
|
|
185
|
+
const chatId = ctx.chat.id;
|
|
186
|
+
const messageId = ctx.message.message_id;
|
|
187
|
+
const caption = ctx.message.caption ?? "";
|
|
188
|
+
// Reject oversized files before downloading (file_size may be undefined for large files)
|
|
189
|
+
if (doc.file_size !== undefined && doc.file_size > FILE_SIZE_LIMIT_BYTES) {
|
|
190
|
+
await ctx.reply("⚠️ File too large (max 5MB). Attachment not processed.");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await ctx.replyWithChatAction("typing");
|
|
194
|
+
const ack = await ctx.reply("📎 Processing attachment…");
|
|
195
|
+
try {
|
|
196
|
+
const { data, mimeType, size } = await downloadTelegramFile(bot, doc.file_id);
|
|
197
|
+
if (size > FILE_SIZE_LIMIT_BYTES) {
|
|
198
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const attachment = {
|
|
202
|
+
type: "blob",
|
|
203
|
+
data,
|
|
204
|
+
mimeType,
|
|
205
|
+
displayName: doc.file_name ?? "document",
|
|
206
|
+
};
|
|
207
|
+
const placeholder = await ctx.reply("…");
|
|
208
|
+
let accumulated = "";
|
|
209
|
+
let lastEditTime = 0;
|
|
210
|
+
let pendingEdit;
|
|
211
|
+
const editReply = async (content) => {
|
|
212
|
+
try {
|
|
213
|
+
const truncated = content.length > TELEGRAM_MAX_LENGTH
|
|
214
|
+
? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
|
|
215
|
+
: content;
|
|
216
|
+
await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
if (!message.includes("message is not modified")) {
|
|
221
|
+
console.error("[io] Failed to edit message:", message);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
await ctx.api.deleteMessage(chatId, ack.message_id);
|
|
226
|
+
await messageHandler(caption, chatId, messageId, (chunk, done) => {
|
|
227
|
+
accumulated += chunk;
|
|
228
|
+
if (done) {
|
|
229
|
+
if (pendingEdit) {
|
|
230
|
+
clearTimeout(pendingEdit);
|
|
231
|
+
pendingEdit = undefined;
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const timeSinceLastEdit = now - lastEditTime;
|
|
237
|
+
if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
|
|
238
|
+
lastEditTime = now;
|
|
239
|
+
void editReply(accumulated);
|
|
240
|
+
}
|
|
241
|
+
else if (!pendingEdit) {
|
|
242
|
+
pendingEdit = setTimeout(() => {
|
|
243
|
+
pendingEdit = undefined;
|
|
244
|
+
lastEditTime = Date.now();
|
|
245
|
+
void editReply(accumulated);
|
|
246
|
+
}, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
|
|
247
|
+
}
|
|
248
|
+
}, [attachment]);
|
|
249
|
+
if (pendingEdit)
|
|
250
|
+
clearTimeout(pendingEdit);
|
|
251
|
+
if (accumulated.length > 0)
|
|
252
|
+
await editReply(accumulated);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
console.error("[io] Error handling document:", message);
|
|
257
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
85
260
|
bot.catch((err) => {
|
|
86
261
|
console.error("[io] Grammy bot error:", err.message);
|
|
87
262
|
});
|
package/dist/wiki/fs.js
CHANGED
|
@@ -149,4 +149,15 @@ export function writeLogFile(content) {
|
|
|
149
149
|
export function getWikiDir() {
|
|
150
150
|
return WIKI_DIR;
|
|
151
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Read all wiki pages for a squad by slug.
|
|
154
|
+
* Returns array of { path, content } for pages under pages/squads/{slug}/.
|
|
155
|
+
*/
|
|
156
|
+
export function readSquadWikiPages(slug) {
|
|
157
|
+
const prefix = `pages/squads/${slug}/`;
|
|
158
|
+
return listPages()
|
|
159
|
+
.filter(p => p.startsWith(prefix))
|
|
160
|
+
.map(p => ({ path: p, content: readPage(p) ?? "" }))
|
|
161
|
+
.filter(entry => entry.content.length > 0);
|
|
162
|
+
}
|
|
152
163
|
//# sourceMappingURL=fs.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readSquadWikiPages, writePage, deletePage } from "./fs.js";
|
|
4
|
+
describe("readSquadWikiPages", () => {
|
|
5
|
+
it("returns empty array for non-existent squad", () => {
|
|
6
|
+
const pages = readSquadWikiPages("nonexistent-squad-xyz");
|
|
7
|
+
assert.ok(Array.isArray(pages));
|
|
8
|
+
assert.equal(pages.length, 0);
|
|
9
|
+
});
|
|
10
|
+
it("returns pages under pages/squads/{slug}/ prefix", () => {
|
|
11
|
+
const testSlug = `test-squad-${Date.now()}`;
|
|
12
|
+
const pagePath = `pages/squads/${testSlug}/workflow.md`;
|
|
13
|
+
try {
|
|
14
|
+
writePage(pagePath, "# Workflow Rules\nAlways use feature branches.");
|
|
15
|
+
const pages = readSquadWikiPages(testSlug);
|
|
16
|
+
assert.equal(pages.length, 1);
|
|
17
|
+
assert.equal(pages[0].path, pagePath);
|
|
18
|
+
assert.ok(pages[0].content.includes("feature branches"));
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
deletePage(pagePath);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
it("filters out empty pages", () => {
|
|
25
|
+
const testSlug = `test-squad-empty-${Date.now()}`;
|
|
26
|
+
const pagePath = `pages/squads/${testSlug}/empty.md`;
|
|
27
|
+
try {
|
|
28
|
+
writePage(pagePath, "");
|
|
29
|
+
const pages = readSquadWikiPages(testSlug);
|
|
30
|
+
assert.equal(pages.length, 0);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
deletePage(pagePath);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it("returns multiple pages for a squad", () => {
|
|
37
|
+
const testSlug = `test-squad-multi-${Date.now()}`;
|
|
38
|
+
const page1 = `pages/squads/${testSlug}/workflow.md`;
|
|
39
|
+
const page2 = `pages/squads/${testSlug}/coding-standards.md`;
|
|
40
|
+
try {
|
|
41
|
+
writePage(page1, "# Workflow\nUse PRs.");
|
|
42
|
+
writePage(page2, "# Standards\nESLint required.");
|
|
43
|
+
const pages = readSquadWikiPages(testSlug);
|
|
44
|
+
assert.equal(pages.length, 2);
|
|
45
|
+
const paths = pages.map(p => p.path).sort();
|
|
46
|
+
assert.deepEqual(paths, [page2, page1].sort());
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
deletePage(page1);
|
|
50
|
+
deletePage(page2);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=wiki-squad.test.js.map
|