chapterhouse 0.3.1 → 0.3.3
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 +73 -1
- package/dist/api/auth.js +4 -3
- package/dist/api/auth.test.js +27 -39
- package/dist/api/server.js +43 -2
- package/dist/api/team.js +4 -2
- package/dist/cli.js +8 -2
- package/dist/config.js +3 -0
- package/dist/copilot/episode-writer.js +4 -2
- package/dist/copilot/orchestrator.js +410 -356
- package/dist/copilot/orchestrator.test.js +244 -0
- package/dist/copilot/session-manager.js +337 -0
- package/dist/copilot/session-manager.test.js +358 -0
- package/dist/copilot/system-message.js +2 -1
- package/dist/copilot/system-message.test.js +8 -0
- package/dist/copilot/workiq-installer.js +91 -0
- package/dist/copilot/workiq-installer.test.js +148 -0
- package/dist/daemon.js +12 -1
- package/dist/integrations/teams-notify.js +3 -1
- package/dist/squad/index.js +1 -0
- package/dist/squad/init-cli.js +109 -0
- package/dist/squad/init.js +395 -0
- package/dist/squad/init.test.js +351 -0
- package/dist/squad/mirror.js +4 -2
- package/dist/squad/mirror.scheduler.js +9 -7
- package/dist/store/db.js +58 -5
- package/dist/store/db.test.js +69 -0
- package/dist/version.js +7 -0
- package/dist/wiki/team-sync.js +3 -1
- package/package.json +4 -3
- package/web/dist/assets/index-BkB7gY18.css +10 -0
- package/web/dist/assets/index-DSqc46G_.js +208 -0
- package/web/dist/assets/index-DSqc46G_.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CxT9905O.css +0 -10
- package/web/dist/assets/index-DI3rnGm-.js +0 -142
- package/web/dist/assets/index-DI3rnGm-.js.map +0 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Tests for SessionManager and SessionRegistry
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { SessionManager, SessionRegistry, } from "./session-manager.js";
|
|
7
|
+
function makeFakeSession() {
|
|
8
|
+
const tracker = {
|
|
9
|
+
disconnectCalls: 0,
|
|
10
|
+
abortCalls: 0,
|
|
11
|
+
session: null,
|
|
12
|
+
};
|
|
13
|
+
tracker.session = {
|
|
14
|
+
sessionId: "fake-" + Math.random().toString(36).slice(2),
|
|
15
|
+
async disconnect() { tracker.disconnectCalls++; },
|
|
16
|
+
async abort() { tracker.abortCalls++; },
|
|
17
|
+
on() { return () => { }; },
|
|
18
|
+
async sendAndWait() { return { data: { content: "ok" } }; },
|
|
19
|
+
async setModel() { },
|
|
20
|
+
getState() { return "connected"; },
|
|
21
|
+
};
|
|
22
|
+
return tracker;
|
|
23
|
+
}
|
|
24
|
+
function factory(session) {
|
|
25
|
+
return async () => session;
|
|
26
|
+
}
|
|
27
|
+
function makeDeferred() {
|
|
28
|
+
let resolve;
|
|
29
|
+
let reject;
|
|
30
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
31
|
+
const item = {
|
|
32
|
+
prompt: "test prompt",
|
|
33
|
+
callback: () => { },
|
|
34
|
+
sessionKey: "default",
|
|
35
|
+
resolve,
|
|
36
|
+
reject,
|
|
37
|
+
};
|
|
38
|
+
return { item, promise, resolve, reject };
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// SessionManager tests
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
test("SessionManager: canEvict is true when idle with empty queue", () => {
|
|
44
|
+
const { session } = makeFakeSession();
|
|
45
|
+
const manager = new SessionManager("default", async () => "ok", factory(session));
|
|
46
|
+
assert.equal(manager.isProcessing, false);
|
|
47
|
+
assert.equal(manager.queueDepth, 0);
|
|
48
|
+
assert.equal(manager.canEvict, true);
|
|
49
|
+
});
|
|
50
|
+
test("SessionManager: canEvict is false while processing, true after", async () => {
|
|
51
|
+
const { session } = makeFakeSession();
|
|
52
|
+
let unblock;
|
|
53
|
+
const worker = () => new Promise((r) => { unblock = () => r("done"); });
|
|
54
|
+
const manager = new SessionManager("default", worker, factory(session));
|
|
55
|
+
const { item } = makeDeferred();
|
|
56
|
+
manager.enqueue(item);
|
|
57
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
58
|
+
assert.equal(manager.isProcessing, true, "should be processing after enqueue");
|
|
59
|
+
assert.equal(manager.canEvict, false, "must not evict while processing");
|
|
60
|
+
unblock();
|
|
61
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
62
|
+
assert.equal(manager.isProcessing, false, "should be idle after worker completes");
|
|
63
|
+
assert.equal(manager.canEvict, true, "can evict once idle");
|
|
64
|
+
});
|
|
65
|
+
test("SessionManager: canEvict is false when queue has pending items", async () => {
|
|
66
|
+
const { session } = makeFakeSession();
|
|
67
|
+
let unblock;
|
|
68
|
+
const worker = () => new Promise((r) => { unblock = () => r("done"); });
|
|
69
|
+
const manager = new SessionManager("default", worker, factory(session));
|
|
70
|
+
const { item: i1 } = makeDeferred();
|
|
71
|
+
const { item: i2 } = makeDeferred();
|
|
72
|
+
manager.enqueue(i1);
|
|
73
|
+
manager.enqueue(i2);
|
|
74
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
75
|
+
assert.equal(manager.queueDepth, 1, "second item still queued");
|
|
76
|
+
assert.equal(manager.canEvict, false, "must not evict with queued items");
|
|
77
|
+
unblock();
|
|
78
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
79
|
+
assert.equal(manager.queueDepth, 0, "queue should drain");
|
|
80
|
+
// Clean up: unblock second (which completes instantly via the same worker closure)
|
|
81
|
+
});
|
|
82
|
+
test("SessionManager: cancelQueued drains pending queue items and rejects them", async () => {
|
|
83
|
+
const { session } = makeFakeSession();
|
|
84
|
+
let unblock;
|
|
85
|
+
const worker = () => new Promise((r) => { unblock = () => r("first"); });
|
|
86
|
+
const manager = new SessionManager("default", worker, factory(session));
|
|
87
|
+
const { item: i1 } = makeDeferred();
|
|
88
|
+
const { item: i2, promise: p2 } = makeDeferred();
|
|
89
|
+
const { item: i3, promise: p3 } = makeDeferred();
|
|
90
|
+
manager.enqueue(i1);
|
|
91
|
+
manager.enqueue(i2);
|
|
92
|
+
manager.enqueue(i3);
|
|
93
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
94
|
+
assert.equal(manager.queueDepth, 2, "two items queued while first processes");
|
|
95
|
+
const drained = manager.cancelQueued();
|
|
96
|
+
assert.equal(drained, 2, "two queued items should be cancelled");
|
|
97
|
+
await assert.rejects(p2, /Cancelled/, "item2 should reject with Cancelled");
|
|
98
|
+
await assert.rejects(p3, /Cancelled/, "item3 should reject with Cancelled");
|
|
99
|
+
unblock();
|
|
100
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
101
|
+
});
|
|
102
|
+
test("SessionManager: evict disconnects session and rejects queued items", async () => {
|
|
103
|
+
const t1 = makeFakeSession();
|
|
104
|
+
let unblock;
|
|
105
|
+
const worker = () => new Promise((r) => { unblock = () => r("ok"); });
|
|
106
|
+
const manager = new SessionManager("default", worker, factory(t1.session));
|
|
107
|
+
const { item: i1 } = makeDeferred();
|
|
108
|
+
const { item: i2, promise: p2 } = makeDeferred();
|
|
109
|
+
manager.enqueue(i1);
|
|
110
|
+
manager.enqueue(i2);
|
|
111
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
112
|
+
// Evict: rejects queued items and disconnects session
|
|
113
|
+
const evictDone = manager.evict("explicit-close");
|
|
114
|
+
await assert.rejects(p2, /evicted/, "queued item should be rejected with evicted");
|
|
115
|
+
await evictDone;
|
|
116
|
+
assert.equal(t1.disconnectCalls, 0, "session not yet created — no disconnect needed");
|
|
117
|
+
// Now with a primed session
|
|
118
|
+
const t2 = makeFakeSession();
|
|
119
|
+
const manager2 = new SessionManager("s2", async () => "ok", factory(t2.session));
|
|
120
|
+
await manager2.ensureSession();
|
|
121
|
+
await manager2.evict("explicit-close");
|
|
122
|
+
assert.equal(t2.disconnectCalls, 1, "session should be disconnected on evict");
|
|
123
|
+
unblock();
|
|
124
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
125
|
+
});
|
|
126
|
+
test("SessionManager: abortCurrentTurn returns false when not processing", async () => {
|
|
127
|
+
const { session } = makeFakeSession();
|
|
128
|
+
const manager = new SessionManager("default", async () => "ok", factory(session));
|
|
129
|
+
await manager.ensureSession();
|
|
130
|
+
const aborted = await manager.abortCurrentTurn();
|
|
131
|
+
assert.equal(aborted, false);
|
|
132
|
+
});
|
|
133
|
+
test("SessionManager: abortCurrentTurn calls session.abort() when processing", async () => {
|
|
134
|
+
const t = makeFakeSession();
|
|
135
|
+
const worker = async (_item, mgr) => {
|
|
136
|
+
await mgr.ensureSession();
|
|
137
|
+
return new Promise(() => { }); // park forever
|
|
138
|
+
};
|
|
139
|
+
const manager = new SessionManager("default", worker, factory(t.session));
|
|
140
|
+
const { item } = makeDeferred();
|
|
141
|
+
manager.enqueue(item);
|
|
142
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
143
|
+
assert.equal(manager.isProcessing, true, "manager must be processing");
|
|
144
|
+
const aborted = await manager.abortCurrentTurn();
|
|
145
|
+
assert.equal(aborted, true, "abortCurrentTurn should return true");
|
|
146
|
+
assert.equal(t.abortCalls, 1, "session.abort() must be called once");
|
|
147
|
+
});
|
|
148
|
+
test("SessionManager: addRecentTier caps at 5 entries", () => {
|
|
149
|
+
const { session } = makeFakeSession();
|
|
150
|
+
const manager = new SessionManager("default", async () => "ok", factory(session));
|
|
151
|
+
for (let i = 0; i < 8; i++)
|
|
152
|
+
manager.addRecentTier("fast");
|
|
153
|
+
assert.equal(manager.recentTiers.length, 5, "should cap at 5 recent tiers");
|
|
154
|
+
});
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// SessionRegistry tests
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
function makeRegistry(opts = {}) {
|
|
159
|
+
const { idleTtlMs = 60_000, maxActive = 10 } = opts;
|
|
160
|
+
const disconnectLog = [];
|
|
161
|
+
const registry = new SessionRegistry({ idleTtlMs, maxActive }, (sk) => {
|
|
162
|
+
const t = makeFakeSession();
|
|
163
|
+
// Instrument disconnect to capture key
|
|
164
|
+
const orig = t.session.disconnect.bind(t.session);
|
|
165
|
+
t.session.disconnect = async () => {
|
|
166
|
+
disconnectLog.push(sk);
|
|
167
|
+
await orig();
|
|
168
|
+
};
|
|
169
|
+
return new SessionManager(sk, async () => "ok", factory(t.session));
|
|
170
|
+
});
|
|
171
|
+
return { registry, disconnectLog };
|
|
172
|
+
}
|
|
173
|
+
test("SessionRegistry: getOrCreate returns same manager for same key", () => {
|
|
174
|
+
const { registry } = makeRegistry();
|
|
175
|
+
const m1 = registry.getOrCreate("default");
|
|
176
|
+
const m2 = registry.getOrCreate("default");
|
|
177
|
+
assert.ok(m1 instanceof SessionManager);
|
|
178
|
+
assert.equal(m1, m2, "same key returns same manager");
|
|
179
|
+
assert.equal(registry.size(), 1);
|
|
180
|
+
});
|
|
181
|
+
test("SessionRegistry: LRU eviction fires when at maxActive capacity", async () => {
|
|
182
|
+
const { registry, disconnectLog } = makeRegistry({ maxActive: 3, idleTtlMs: 60_000 });
|
|
183
|
+
// Create 3 sessions with staggered timestamps so LRU is deterministic
|
|
184
|
+
const m1 = registry.getOrCreate("s1");
|
|
185
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
186
|
+
registry.getOrCreate("s2");
|
|
187
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
188
|
+
registry.getOrCreate("s3");
|
|
189
|
+
// Prime s1's session so it gets disconnected
|
|
190
|
+
await m1.ensureSession();
|
|
191
|
+
assert.equal(registry.size(), 3);
|
|
192
|
+
// Adding a 4th should evict s1 (oldest lastActivityAt)
|
|
193
|
+
registry.getOrCreate("s4");
|
|
194
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
195
|
+
assert.equal(registry.size(), 3, "size should remain at max");
|
|
196
|
+
assert.ok(disconnectLog.includes("s1"), "s1 (oldest) should be LRU-evicted");
|
|
197
|
+
assert.ok(!registry.get("s1"), "s1 should be removed from registry");
|
|
198
|
+
});
|
|
199
|
+
test("SessionRegistry: explicit close evicts an idle session", async () => {
|
|
200
|
+
const { registry, disconnectLog } = makeRegistry();
|
|
201
|
+
const m = registry.getOrCreate("my-session");
|
|
202
|
+
await m.ensureSession();
|
|
203
|
+
assert.equal(registry.size(), 1);
|
|
204
|
+
registry.close("my-session", "explicit-close");
|
|
205
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
206
|
+
assert.equal(registry.size(), 0, "session should be removed");
|
|
207
|
+
assert.ok(disconnectLog.includes("my-session"), "session should be disconnected");
|
|
208
|
+
});
|
|
209
|
+
test("SessionRegistry: close is deferred when session is processing", async () => {
|
|
210
|
+
const { registry, disconnectLog } = makeRegistry();
|
|
211
|
+
let unblock;
|
|
212
|
+
// Replace the factory with a blocking one
|
|
213
|
+
const { registry: reg2, disconnectLog: dl2 } = (() => {
|
|
214
|
+
const log = [];
|
|
215
|
+
const r = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
216
|
+
const t = makeFakeSession();
|
|
217
|
+
t.session.disconnect = async () => { log.push(sk); };
|
|
218
|
+
const worker = () => new Promise((res) => { unblock = () => res("done"); });
|
|
219
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
220
|
+
});
|
|
221
|
+
return { registry: r, disconnectLog: log };
|
|
222
|
+
})();
|
|
223
|
+
const m = reg2.getOrCreate("busy");
|
|
224
|
+
const { item } = makeDeferred();
|
|
225
|
+
m.enqueue(item);
|
|
226
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
227
|
+
assert.equal(m.isProcessing, true);
|
|
228
|
+
// close() should be deferred (session is busy)
|
|
229
|
+
reg2.close("busy", "explicit-close");
|
|
230
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
231
|
+
assert.ok(reg2.get("busy"), "busy session must remain in registry");
|
|
232
|
+
assert.equal(dl2.length, 0, "disconnect must not fire while processing");
|
|
233
|
+
unblock();
|
|
234
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
235
|
+
});
|
|
236
|
+
test("SessionRegistry: pendingClose evicts session within ms of turn completion", async () => {
|
|
237
|
+
let unblock;
|
|
238
|
+
const disconnectLog = [];
|
|
239
|
+
const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
240
|
+
const t = makeFakeSession();
|
|
241
|
+
t.session.disconnect = async () => { disconnectLog.push(sk); };
|
|
242
|
+
const worker = () => new Promise((res) => { unblock = () => res("done"); });
|
|
243
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
244
|
+
});
|
|
245
|
+
const m = registry.getOrCreate("session-a");
|
|
246
|
+
await m.ensureSession();
|
|
247
|
+
const { item } = makeDeferred();
|
|
248
|
+
m.enqueue(item);
|
|
249
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
250
|
+
assert.equal(m.isProcessing, true, "should be processing");
|
|
251
|
+
// Close while busy — should set pendingClose, not evict yet
|
|
252
|
+
registry.close("session-a", "explicit-close");
|
|
253
|
+
assert.equal(m.pendingClose, true, "pendingClose must be set");
|
|
254
|
+
assert.ok(registry.get("session-a"), "session must remain in registry until turn finishes");
|
|
255
|
+
assert.equal(disconnectLog.length, 0, "must not disconnect mid-turn");
|
|
256
|
+
// Unblock the turn — session should be evicted within ms
|
|
257
|
+
unblock();
|
|
258
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
259
|
+
assert.ok(!registry.get("session-a"), "session must be evicted after turn completes");
|
|
260
|
+
assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected");
|
|
261
|
+
});
|
|
262
|
+
test("SessionRegistry: pendingClose waits for full queue drain before evicting", async () => {
|
|
263
|
+
let unblock1;
|
|
264
|
+
let unblock2;
|
|
265
|
+
const disconnectLog = [];
|
|
266
|
+
let callCount = 0;
|
|
267
|
+
const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
268
|
+
const t = makeFakeSession();
|
|
269
|
+
t.session.disconnect = async () => { disconnectLog.push(sk); };
|
|
270
|
+
const worker = () => {
|
|
271
|
+
callCount++;
|
|
272
|
+
if (callCount === 1) {
|
|
273
|
+
return new Promise((res) => { unblock1 = () => res("turn1"); });
|
|
274
|
+
}
|
|
275
|
+
return new Promise((res) => { unblock2 = () => res("turn2"); });
|
|
276
|
+
};
|
|
277
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
278
|
+
});
|
|
279
|
+
const m = registry.getOrCreate("session-a");
|
|
280
|
+
await m.ensureSession();
|
|
281
|
+
const { item: item1 } = makeDeferred();
|
|
282
|
+
const { item: item2 } = makeDeferred();
|
|
283
|
+
m.enqueue(item1); // starts turn 1 immediately
|
|
284
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
285
|
+
m.enqueue(item2); // queued while turn 1 runs
|
|
286
|
+
assert.equal(m.isProcessing, true, "should be processing turn 1");
|
|
287
|
+
assert.equal(m.queueDepth, 1, "turn 2 should be queued");
|
|
288
|
+
// Close while busy — should set pendingClose
|
|
289
|
+
registry.close("session-a", "explicit-close");
|
|
290
|
+
assert.equal(m.pendingClose, true, "pendingClose must be set");
|
|
291
|
+
// Unblock turn 1 — turn 2 is still queued, session must NOT evict yet
|
|
292
|
+
unblock1();
|
|
293
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
294
|
+
assert.ok(registry.get("session-a"), "session must remain while turn 2 is still queued");
|
|
295
|
+
assert.equal(disconnectLog.length, 0, "must not disconnect until queue fully drains");
|
|
296
|
+
// Unblock turn 2 — queue is now empty, session must evict
|
|
297
|
+
unblock2();
|
|
298
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
299
|
+
assert.ok(!registry.get("session-a"), "session must be evicted after queue fully drains");
|
|
300
|
+
assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected after drain");
|
|
301
|
+
});
|
|
302
|
+
test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async () => {
|
|
303
|
+
const SHORT_TTL = 40;
|
|
304
|
+
const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
|
|
305
|
+
const m = registry.getOrCreate("idle-session");
|
|
306
|
+
await m.ensureSession();
|
|
307
|
+
registry.startEvictionTimer();
|
|
308
|
+
// Wait past 3 × TTL — the eviction scan interval is min(TTL/2, 60s) = 20ms
|
|
309
|
+
await new Promise((r) => setTimeout(r, SHORT_TTL * 5));
|
|
310
|
+
registry.stopEvictionTimer();
|
|
311
|
+
assert.ok(disconnectLog.includes("idle-session"), "idle session must be evicted after TTL");
|
|
312
|
+
assert.ok(!registry.get("idle-session"), "idle session must be removed");
|
|
313
|
+
});
|
|
314
|
+
test("SessionRegistry: shutdown disconnects all sessions", async () => {
|
|
315
|
+
const { registry, disconnectLog } = makeRegistry();
|
|
316
|
+
for (const sk of ["a", "b", "c"]) {
|
|
317
|
+
const m = registry.getOrCreate(sk);
|
|
318
|
+
await m.ensureSession();
|
|
319
|
+
}
|
|
320
|
+
assert.equal(registry.size(), 3);
|
|
321
|
+
await registry.shutdown();
|
|
322
|
+
assert.equal(registry.size(), 0, "registry must be empty after shutdown");
|
|
323
|
+
assert.equal(disconnectLog.length, 3, "all sessions must be disconnected");
|
|
324
|
+
});
|
|
325
|
+
test("SessionRegistry: concurrent sessions process independently", async () => {
|
|
326
|
+
const completionOrder = [];
|
|
327
|
+
let unblockA;
|
|
328
|
+
const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
329
|
+
const t = makeFakeSession();
|
|
330
|
+
const worker = async (_item, _mgr) => {
|
|
331
|
+
if (sk === "session-a") {
|
|
332
|
+
await new Promise((r) => { unblockA = r; });
|
|
333
|
+
}
|
|
334
|
+
completionOrder.push(sk);
|
|
335
|
+
return `${sk} done`;
|
|
336
|
+
};
|
|
337
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
338
|
+
});
|
|
339
|
+
const mA = registry.getOrCreate("session-a");
|
|
340
|
+
const mB = registry.getOrCreate("session-b");
|
|
341
|
+
const { item: iA, promise: pA } = makeDeferred();
|
|
342
|
+
const { item: iB, promise: pB } = makeDeferred();
|
|
343
|
+
mA.enqueue(iA);
|
|
344
|
+
mB.enqueue(iB);
|
|
345
|
+
// Session B should complete before session A (A is parked)
|
|
346
|
+
const resultB = await Promise.race([
|
|
347
|
+
pB,
|
|
348
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("session B was blocked by session A")), 200)),
|
|
349
|
+
]);
|
|
350
|
+
assert.equal(resultB, "session-b done", "session B must complete independently");
|
|
351
|
+
assert.equal(completionOrder[0], "session-b", "session B must finish first");
|
|
352
|
+
assert.ok(mA.isProcessing, "session A should still be processing");
|
|
353
|
+
unblockA();
|
|
354
|
+
const resultA = await pA;
|
|
355
|
+
assert.equal(resultA, "session-a done");
|
|
356
|
+
assert.deepEqual(completionOrder, ["session-b", "session-a"]);
|
|
357
|
+
});
|
|
358
|
+
//# sourceMappingURL=session-manager.test.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getExampleProjectPath } from "../home-path.js";
|
|
2
2
|
export function getOrchestratorSystemMessage(opts) {
|
|
3
|
+
const versionBanner = opts?.version ? `\nYou are running inside chapterhouse v${opts.version}.\n` : "";
|
|
3
4
|
const memoryBlock = opts?.memorySummary
|
|
4
5
|
? `\n## Memory\nYou have a persistent memory store. Here's what you currently remember:\n\n${opts.memorySummary}\n`
|
|
5
6
|
: "\n## Memory\nYou have a persistent memory store. It's currently empty — use `remember` to start building it!\n";
|
|
@@ -25,7 +26,7 @@ This restriction does NOT apply to:
|
|
|
25
26
|
: "";
|
|
26
27
|
const osName = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux";
|
|
27
28
|
return `You are Chapterhouse, a team-level AI assistant for engineering teams running 24/7 on the user's machine (${osName}). You are the engineering team's always-on assistant.
|
|
28
|
-
|
|
29
|
+
${versionBanner}
|
|
29
30
|
${userContextBlock}
|
|
30
31
|
## Your Architecture
|
|
31
32
|
|
|
@@ -14,4 +14,12 @@ test("orchestrator prompt expands shorthand paths with the current home director
|
|
|
14
14
|
assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
15
15
|
assert.doesNotMatch(message, /"~\/dev\/myapp"/);
|
|
16
16
|
});
|
|
17
|
+
test("orchestrator prompt includes chapterhouse version banner when version is provided", () => {
|
|
18
|
+
const message = getOrchestratorSystemMessage({ version: "1.2.3" });
|
|
19
|
+
assert.match(message, /chapterhouse v1\.2\.3/);
|
|
20
|
+
});
|
|
21
|
+
test("orchestrator prompt omits version banner when version is not provided", () => {
|
|
22
|
+
const message = getOrchestratorSystemMessage();
|
|
23
|
+
assert.doesNotMatch(message, /chapterhouse v\d/);
|
|
24
|
+
});
|
|
17
25
|
//# sourceMappingURL=system-message.test.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { mkdirSync as fsMkdirSync, readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync } from "fs";
|
|
4
|
+
import { childLogger } from "../util/logger.js";
|
|
5
|
+
const log = childLogger("workiq-installer");
|
|
6
|
+
export const WORKIQ_SERVER_KEY = "workiq";
|
|
7
|
+
export const WORKIQ_PACKAGE = "@microsoft/workiq";
|
|
8
|
+
export const MCP_CONFIG_PATH = join(homedir(), ".copilot", "mcp-config.json");
|
|
9
|
+
/** Return true if the auto-install feature is active for the given config. */
|
|
10
|
+
export function isWorkiqAutoInstallEnabled(opts) {
|
|
11
|
+
return opts.workiqAutoInstall && opts.entraAuthEnabled && Boolean(opts.entraTenantId);
|
|
12
|
+
}
|
|
13
|
+
/** Parse the raw JSON of a mcp-config.json file. Returns an empty object on any error. */
|
|
14
|
+
export function parseMcpConfigFile(raw) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Return true if the workiq entry already exists in the config. */
|
|
27
|
+
export function workiqEntryExists(config) {
|
|
28
|
+
return Boolean(config.mcpServers &&
|
|
29
|
+
typeof config.mcpServers === "object" &&
|
|
30
|
+
WORKIQ_SERVER_KEY in config.mcpServers);
|
|
31
|
+
}
|
|
32
|
+
/** Build the workiq MCPStdioServerConfig entry. */
|
|
33
|
+
export function buildWorkiqEntry() {
|
|
34
|
+
return {
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", WORKIQ_PACKAGE],
|
|
37
|
+
tools: ["*"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Ensure the workiq MCP server entry is present in ~/.copilot/mcp-config.json.
|
|
42
|
+
*
|
|
43
|
+
* - Idempotent: if the entry already exists, returns early without writing.
|
|
44
|
+
* - Failure-safe: any I/O error is caught; a structured warn is emitted and
|
|
45
|
+
* the function returns without throwing, so callers (daemon startup) continue.
|
|
46
|
+
*
|
|
47
|
+
* Returns "installed" | "already-present" | "skipped" for test assertions.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureWorkiqMcpEntry(options = {}) {
|
|
50
|
+
const configPath = options.configPath ?? MCP_CONFIG_PATH;
|
|
51
|
+
const readFileFn = options.readFile ?? ((p, enc) => fsReadFileSync(p, enc));
|
|
52
|
+
const writeFileFn = options.writeFile ?? ((p, data, enc) => fsWriteFileSync(p, data, enc));
|
|
53
|
+
const mkdirFn = options.mkdirSync ?? ((p, opts) => fsMkdirSync(p, opts));
|
|
54
|
+
try {
|
|
55
|
+
let existingConfig = {};
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileFn(configPath, "utf-8");
|
|
58
|
+
existingConfig = parseMcpConfigFile(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// File doesn't exist yet — start with empty config.
|
|
62
|
+
}
|
|
63
|
+
if (workiqEntryExists(existingConfig)) {
|
|
64
|
+
log.debug({ configPath }, "workiq MCP entry already present — skipping");
|
|
65
|
+
return "already-present";
|
|
66
|
+
}
|
|
67
|
+
// Ensure the parent directory exists
|
|
68
|
+
const parentDir = join(configPath, "..");
|
|
69
|
+
try {
|
|
70
|
+
mkdirFn(parentDir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Best effort — directory may already exist; writeFile will fail loudly if not.
|
|
74
|
+
}
|
|
75
|
+
const updated = {
|
|
76
|
+
...existingConfig,
|
|
77
|
+
mcpServers: {
|
|
78
|
+
...(existingConfig.mcpServers ?? {}),
|
|
79
|
+
[WORKIQ_SERVER_KEY]: buildWorkiqEntry(),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
writeFileFn(configPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
83
|
+
log.info({ configPath, package: WORKIQ_PACKAGE }, "workiq MCP server auto-installed");
|
|
84
|
+
return "installed";
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
log.warn({ err: err instanceof Error ? err.message : String(err), configPath }, "workiq MCP auto-install failed — continuing without it");
|
|
88
|
+
return "skipped";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=workiq-installer.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { isWorkiqAutoInstallEnabled, parseMcpConfigFile, workiqEntryExists, buildWorkiqEntry, ensureWorkiqMcpEntry, WORKIQ_SERVER_KEY, WORKIQ_PACKAGE, } from "./workiq-installer.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// isWorkiqAutoInstallEnabled
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
test("isWorkiqAutoInstallEnabled: true when all conditions met", () => {
|
|
8
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: true }), true);
|
|
9
|
+
});
|
|
10
|
+
test("isWorkiqAutoInstallEnabled: false when workiqAutoInstall disabled", () => {
|
|
11
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: false }), false);
|
|
12
|
+
});
|
|
13
|
+
test("isWorkiqAutoInstallEnabled: false when entraAuthEnabled is false", () => {
|
|
14
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: false, entraTenantId: "tenant-id", workiqAutoInstall: true }), false);
|
|
15
|
+
});
|
|
16
|
+
test("isWorkiqAutoInstallEnabled: false when entraTenantId is empty", () => {
|
|
17
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "", workiqAutoInstall: true }), false);
|
|
18
|
+
});
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// parseMcpConfigFile
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
test("parseMcpConfigFile: parses valid JSON with mcpServers", () => {
|
|
23
|
+
const raw = JSON.stringify({ mcpServers: { existing: {} } });
|
|
24
|
+
const result = parseMcpConfigFile(raw);
|
|
25
|
+
assert.deepEqual(result, { mcpServers: { existing: {} } });
|
|
26
|
+
});
|
|
27
|
+
test("parseMcpConfigFile: returns empty object for invalid JSON", () => {
|
|
28
|
+
const result = parseMcpConfigFile("not-json");
|
|
29
|
+
assert.deepEqual(result, {});
|
|
30
|
+
});
|
|
31
|
+
test("parseMcpConfigFile: returns empty object for null", () => {
|
|
32
|
+
const result = parseMcpConfigFile("null");
|
|
33
|
+
assert.deepEqual(result, {});
|
|
34
|
+
});
|
|
35
|
+
test("parseMcpConfigFile: returns empty object for array", () => {
|
|
36
|
+
const result = parseMcpConfigFile("[]");
|
|
37
|
+
assert.deepEqual(result, {});
|
|
38
|
+
});
|
|
39
|
+
test("parseMcpConfigFile: parses JSON without mcpServers key", () => {
|
|
40
|
+
const result = parseMcpConfigFile('{"other":"value"}');
|
|
41
|
+
assert.deepEqual(result, { other: "value" });
|
|
42
|
+
});
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// workiqEntryExists
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test("workiqEntryExists: true when workiq key present", () => {
|
|
47
|
+
assert.equal(workiqEntryExists({ mcpServers: { workiq: {} } }), true);
|
|
48
|
+
});
|
|
49
|
+
test("workiqEntryExists: false when workiq key absent", () => {
|
|
50
|
+
assert.equal(workiqEntryExists({ mcpServers: { other: {} } }), false);
|
|
51
|
+
});
|
|
52
|
+
test("workiqEntryExists: false when mcpServers missing", () => {
|
|
53
|
+
assert.equal(workiqEntryExists({}), false);
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// buildWorkiqEntry
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
test("buildWorkiqEntry: returns correct npx entry shape", () => {
|
|
59
|
+
const entry = buildWorkiqEntry();
|
|
60
|
+
assert.equal(entry.command, "npx");
|
|
61
|
+
assert.deepEqual(entry.args, ["-y", WORKIQ_PACKAGE]);
|
|
62
|
+
assert.deepEqual(entry.tools, ["*"]);
|
|
63
|
+
});
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// ensureWorkiqMcpEntry
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
function makeFs(initialJson) {
|
|
68
|
+
let stored = initialJson ?? null;
|
|
69
|
+
const written = [];
|
|
70
|
+
return {
|
|
71
|
+
readFile: (path, _enc) => {
|
|
72
|
+
if (stored === null)
|
|
73
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
74
|
+
return stored;
|
|
75
|
+
},
|
|
76
|
+
writeFile: (path, data, _enc) => {
|
|
77
|
+
stored = data;
|
|
78
|
+
written.push(data);
|
|
79
|
+
},
|
|
80
|
+
mkdirSync: (_path, _opts) => { },
|
|
81
|
+
written,
|
|
82
|
+
getStored: () => stored,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
test("ensureWorkiqMcpEntry: installs when file does not exist", () => {
|
|
86
|
+
const fs = makeFs();
|
|
87
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
88
|
+
assert.equal(result, "installed");
|
|
89
|
+
const written = JSON.parse(fs.getStored());
|
|
90
|
+
assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY]);
|
|
91
|
+
assert.equal(written.mcpServers[WORKIQ_SERVER_KEY].command, "npx");
|
|
92
|
+
});
|
|
93
|
+
test("ensureWorkiqMcpEntry: installs when file exists without workiq entry", () => {
|
|
94
|
+
const initial = JSON.stringify({ mcpServers: { other: { command: "node", args: [], tools: ["*"] } } });
|
|
95
|
+
const fs = makeFs(initial);
|
|
96
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
97
|
+
assert.equal(result, "installed");
|
|
98
|
+
const written = JSON.parse(fs.getStored());
|
|
99
|
+
assert.ok(written.mcpServers?.other, "existing entry preserved");
|
|
100
|
+
assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY], "workiq entry added");
|
|
101
|
+
});
|
|
102
|
+
test("ensureWorkiqMcpEntry: already-present when workiq entry exists", () => {
|
|
103
|
+
const initial = JSON.stringify({ mcpServers: { workiq: { command: "npx", args: ["-y", "@microsoft/workiq"], tools: ["*"] } } });
|
|
104
|
+
const fs = makeFs(initial);
|
|
105
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
106
|
+
assert.equal(result, "already-present");
|
|
107
|
+
assert.equal(fs.written.length, 0, "no write performed");
|
|
108
|
+
});
|
|
109
|
+
test("ensureWorkiqMcpEntry: idempotent on second call", () => {
|
|
110
|
+
const fs = makeFs();
|
|
111
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
112
|
+
const result2 = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
113
|
+
assert.equal(result2, "already-present");
|
|
114
|
+
assert.equal(fs.written.length, 1, "only one write total");
|
|
115
|
+
});
|
|
116
|
+
test("ensureWorkiqMcpEntry: returns skipped on write failure", () => {
|
|
117
|
+
const failingWrite = (_p, _d, _e) => {
|
|
118
|
+
throw new Error("EROFS: read-only file system");
|
|
119
|
+
};
|
|
120
|
+
const result = ensureWorkiqMcpEntry({
|
|
121
|
+
configPath: "/fake/mcp-config.json",
|
|
122
|
+
readFile: (_p, _e) => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); },
|
|
123
|
+
writeFile: failingWrite,
|
|
124
|
+
mkdirSync: () => { },
|
|
125
|
+
});
|
|
126
|
+
assert.equal(result, "skipped");
|
|
127
|
+
});
|
|
128
|
+
test("ensureWorkiqMcpEntry: preserves other mcpServers entries on install", () => {
|
|
129
|
+
const initial = JSON.stringify({
|
|
130
|
+
mcpServers: {
|
|
131
|
+
custom: { command: "node", args: ["server.js"], tools: ["myTool"] },
|
|
132
|
+
},
|
|
133
|
+
someOtherKey: true,
|
|
134
|
+
});
|
|
135
|
+
const fs = makeFs(initial);
|
|
136
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
137
|
+
const written = JSON.parse(fs.getStored());
|
|
138
|
+
assert.ok(written.mcpServers?.custom, "existing server preserved");
|
|
139
|
+
assert.equal(written.someOtherKey, true, "unrelated keys preserved");
|
|
140
|
+
});
|
|
141
|
+
test("ensureWorkiqMcpEntry: creates well-formed JSON ending in newline", () => {
|
|
142
|
+
const fs = makeFs();
|
|
143
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
144
|
+
assert.ok(fs.getStored().endsWith("\n"), "file ends with newline");
|
|
145
|
+
// Verify it's valid JSON
|
|
146
|
+
assert.doesNotThrow(() => JSON.parse(fs.getStored()));
|
|
147
|
+
});
|
|
148
|
+
//# sourceMappingURL=workiq-installer.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -17,6 +17,8 @@ import { StandupScheduler } from "./copilot/standup.js";
|
|
|
17
17
|
import { DecisionsSyncScheduler } from "./squad/mirror.scheduler.js";
|
|
18
18
|
import { registerShutdownSignals } from "./shutdown-signals.js";
|
|
19
19
|
import { logger } from "./util/logger.js";
|
|
20
|
+
import { CHAPTERHOUSE_VERSION } from "./version.js";
|
|
21
|
+
import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
|
|
20
22
|
const log = logger.child({ module: "daemon" });
|
|
21
23
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
24
|
/**
|
|
@@ -78,7 +80,7 @@ function truncate(text, max = 200) {
|
|
|
78
80
|
return oneLine.length > max ? oneLine.slice(0, max) + "…" : oneLine;
|
|
79
81
|
}
|
|
80
82
|
async function main() {
|
|
81
|
-
log.info("Starting Chapterhouse daemon");
|
|
83
|
+
log.info({ version: CHAPTERHOUSE_VERSION }, "Starting Chapterhouse daemon");
|
|
82
84
|
if (config.selfEditEnabled) {
|
|
83
85
|
log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
|
|
84
86
|
}
|
|
@@ -118,6 +120,15 @@ async function main() {
|
|
|
118
120
|
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
119
121
|
log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
|
|
120
122
|
}
|
|
123
|
+
// Auto-install workiq MCP server when Entra is configured
|
|
124
|
+
if (isWorkiqAutoInstallEnabled({
|
|
125
|
+
entraAuthEnabled: config.entraAuthEnabled,
|
|
126
|
+
entraTenantId: config.entraTenantId,
|
|
127
|
+
workiqAutoInstall: config.workiqAutoInstall,
|
|
128
|
+
})) {
|
|
129
|
+
log.info("Entra auth detected — ensuring workiq MCP server is configured");
|
|
130
|
+
ensureWorkiqMcpEntry();
|
|
131
|
+
}
|
|
121
132
|
// Start Copilot SDK client
|
|
122
133
|
log.info("Starting Copilot SDK client");
|
|
123
134
|
const client = await getClient();
|