crewswarm 0.9.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -1
- package/README.md +58 -9
- package/apps/dashboard/README.md +49 -0
- package/apps/dashboard/dist/assets/{index-D-sRshvg.css → index-C5-vlIwl.css} +1 -1
- package/apps/dashboard/dist/assets/index-CSooN9fi.js +2 -0
- package/apps/dashboard/dist/assets/index-CSooN9fi.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js +1 -0
- package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-testing-tab-Ea5K-rsb.js +1 -0
- package/apps/dashboard/dist/index.html +85 -7
- package/apps/dashboard/dist/index.html.br +0 -0
- package/contrib/openclaw-plugin/index.ts +20 -11
- package/install.sh +2 -2
- package/lib/autoharness/index.mjs +151 -1
- package/lib/chat/history.mjs +1 -1
- package/lib/contacts/identity-linker.mjs +24 -3
- package/lib/contacts/index.mjs +2 -1
- package/lib/crew-lead/chat-handler.mjs +56 -33
- package/lib/crew-lead/llm-caller.mjs +71 -14
- package/lib/crew-lead/prompts.mjs +4 -2
- package/lib/crew-lead/wave-dispatcher.mjs +53 -3
- package/lib/crew-lead/worktree.mjs +258 -0
- package/lib/crew-lead/ws-router.mjs +43 -0
- package/lib/engines/rt-envelope.mjs +4 -1
- package/lib/memory/relevance-scorer.mjs +199 -0
- package/lib/memory/shared-adapter.mjs +85 -19
- package/package.json +10 -3
- package/scripts/dashboard.mjs +398 -28
- package/scripts/health-check.mjs +70 -28
- package/scripts/install-docker.sh +1 -1
- package/scripts/restart-all-from-repo.sh +25 -21
- package/scripts/start.mjs +81 -26
- package/apps/dashboard/dist/assets/chat-core-uXb_C0GM.js.br +0 -0
- package/apps/dashboard/dist/assets/cli-process-CNZ_UBCt.js.br +0 -0
- package/apps/dashboard/dist/assets/components-BS9fQjE_.js.br +0 -0
- package/apps/dashboard/dist/assets/core-utils-CmOkXgzi.js.br +0 -0
- package/apps/dashboard/dist/assets/index-BeVllEj_.js +0 -2
- package/apps/dashboard/dist/assets/index-BeVllEj_.js.br +0 -0
- package/apps/dashboard/dist/assets/index-D-sRshvg.css.br +0 -0
- package/apps/dashboard/dist/assets/orchestration-Ca2DLWN-.js.br +0 -0
- package/apps/dashboard/dist/assets/setup-wizard-CA0Or47w.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-agents-tab-BgpIsjkw.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-benchmarks-tab-BHjKCPm3.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-comms-tab-kguqTIzD.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-contacts-tab-DiOyMYth.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-engines-tab-BsdZVvU0.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-memory-tab-Cu6u13EQ.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-models-tab-dNRgsTOO.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-pm-loop-tab-DiAPTJXu.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-projects-tab-SFH4E--a.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-prompts-tab-DVkUNaJd.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-services-tab-DU_LH3uG.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-settings-tab-CuvH_Fj_.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-skills-tab-DR7PJ7NB.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js +0 -1
- package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-swarm-chat-tab-BNrd88-r.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-swarm-tab-B1AcjL1W.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js +0 -1
- package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-usage-tab-BIOOnB-Y.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-waves-tab-SaJDkb4x.js.br +0 -0
- package/apps/dashboard/dist/assets/tab-workflows-tab-B-soSy1k.js.br +0 -0
- package/apps/dashboard/dist/index.html.gz +0 -0
package/scripts/dashboard.mjs
CHANGED
|
@@ -132,9 +132,21 @@ const OAUTH_TOKEN_TTL_MS = 25 * 60 * 1000; // 25 minutes (tokens typically expir
|
|
|
132
132
|
// Capture execFileSync at module load for sync token refresh
|
|
133
133
|
const { execFileSync: _oauthExecFileSync } = await import("node:child_process");
|
|
134
134
|
|
|
135
|
+
function getKeychainAccountCandidates() {
|
|
136
|
+
const candidates = [
|
|
137
|
+
process.env.CLAUDE_CODE_ACCOUNT,
|
|
138
|
+
process.env.USER,
|
|
139
|
+
process.env.LOGNAME,
|
|
140
|
+
];
|
|
141
|
+
try {
|
|
142
|
+
candidates.unshift(os.userInfo().username);
|
|
143
|
+
} catch { /* os user lookup can fail in restricted environments */ }
|
|
144
|
+
return [...new Set(candidates.filter(Boolean))];
|
|
145
|
+
}
|
|
146
|
+
|
|
135
147
|
function refreshAnthropicOAuthToken() {
|
|
136
148
|
try {
|
|
137
|
-
for (const acct of
|
|
149
|
+
for (const acct of getKeychainAccountCandidates()) {
|
|
138
150
|
try {
|
|
139
151
|
const raw = _oauthExecFileSync("security", [
|
|
140
152
|
"find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
|
|
@@ -152,7 +164,7 @@ function refreshAnthropicOAuthToken() {
|
|
|
152
164
|
|
|
153
165
|
// Initial load at startup
|
|
154
166
|
try {
|
|
155
|
-
for (const acct of
|
|
167
|
+
for (const acct of getKeychainAccountCandidates()) {
|
|
156
168
|
try {
|
|
157
169
|
const raw = _oauthExecFileSync("security", [
|
|
158
170
|
"find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
|
|
@@ -1581,16 +1593,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
1581
1593
|
// by inspecting run.json test_command
|
|
1582
1594
|
async function detectSuite(runDir) {
|
|
1583
1595
|
try {
|
|
1584
|
-
|
|
1585
|
-
|
|
1596
|
+
// Try run.json first
|
|
1597
|
+
let cmd = "";
|
|
1598
|
+
try {
|
|
1599
|
+
const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
|
|
1600
|
+
cmd = r.test_command || "";
|
|
1601
|
+
} catch {
|
|
1602
|
+
// No run.json — try to infer from directory contents
|
|
1603
|
+
try {
|
|
1604
|
+
const ents = await fs.promises.readdir(runDir);
|
|
1605
|
+
const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
|
|
1606
|
+
// Check first test dir name for suite hint
|
|
1607
|
+
const first = testDirs[0] || "";
|
|
1608
|
+
if (first.includes("test-e2e-")) return "e2e";
|
|
1609
|
+
if (first.includes("test-integration-")) return "integration";
|
|
1610
|
+
if (first.includes("test-unit-")) return "unit";
|
|
1611
|
+
if (first.includes("spec-")) return "playwright";
|
|
1612
|
+
} catch {}
|
|
1613
|
+
return "unknown";
|
|
1614
|
+
}
|
|
1615
|
+
if (cmd.includes("test:playwright") || cmd.includes("playwright test")) return "playwright";
|
|
1586
1616
|
if (cmd.includes("test/e2e/") || cmd.includes("test:e2e")) return "e2e";
|
|
1587
1617
|
if (cmd.includes("test/integration/")) return "integration";
|
|
1588
1618
|
if (cmd.includes("test/unit/")) {
|
|
1589
|
-
// If it has ONLY unit tests, it's a unit run; if mixed, it's "all"
|
|
1590
1619
|
if (cmd.includes("test/integration/") || cmd.includes("test/e2e/")) return "all";
|
|
1591
1620
|
return "unit";
|
|
1592
1621
|
}
|
|
1593
|
-
|
|
1622
|
+
if (cmd.includes("test:all")) return "all";
|
|
1623
|
+
if (cmd.includes("crew-cli") || cmd.includes("--prefix crew-cli")) return "crew-cli";
|
|
1594
1624
|
const fileCount = (cmd.match(/\.test\.mjs/g) || []).length;
|
|
1595
1625
|
if (fileCount > 100) return "all";
|
|
1596
1626
|
if (fileCount > 15) return "unit";
|
|
@@ -1699,7 +1729,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1699
1729
|
let count = 0;
|
|
1700
1730
|
try {
|
|
1701
1731
|
for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
1702
|
-
if (ent.isDirectory()) { count += countTestCallsRecursive(path.join(dir, ent.name)); continue; }
|
|
1732
|
+
if (ent.isDirectory()) { count += await countTestCallsRecursive(path.join(dir, ent.name)); continue; }
|
|
1703
1733
|
if (!ent.name.match(/\.test\./)) continue;
|
|
1704
1734
|
const src = await fs.promises.readFile(path.join(dir, ent.name), "utf8");
|
|
1705
1735
|
const matches = src.match(/^\s*(test|it)\s*\(/gm);
|
|
@@ -1708,13 +1738,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1708
1738
|
} catch {}
|
|
1709
1739
|
return count;
|
|
1710
1740
|
}
|
|
1741
|
+
const [unitTests, intTests, e2eTests, pwTests, cliTests1, cliTests2, rootTests] = await Promise.all([
|
|
1742
|
+
countTestCalls(path.join(testFileDir, "unit"), /\.test\.mjs$/),
|
|
1743
|
+
countTestCalls(path.join(testFileDir, "integration"), /\.test\.mjs$/),
|
|
1744
|
+
countTestCalls(path.join(testFileDir, "e2e"), /\.test\.mjs$/),
|
|
1745
|
+
countTestCalls(testsE2eDir, /\.spec\.js$/),
|
|
1746
|
+
countTestCallsRecursive(crewCliTestDir),
|
|
1747
|
+
countTestCallsRecursive(crewCliTestDir2),
|
|
1748
|
+
countTestCalls(testFileDir, /\.test\./),
|
|
1749
|
+
]);
|
|
1711
1750
|
const testCounts = {
|
|
1712
|
-
unit:
|
|
1713
|
-
integration:
|
|
1714
|
-
e2e:
|
|
1715
|
-
playwright:
|
|
1716
|
-
"crew-cli":
|
|
1717
|
-
root:
|
|
1751
|
+
unit: unitTests,
|
|
1752
|
+
integration: intTests,
|
|
1753
|
+
e2e: e2eTests,
|
|
1754
|
+
playwright: pwTests,
|
|
1755
|
+
"crew-cli": cliTests1 + cliTests2,
|
|
1756
|
+
root: rootTests,
|
|
1718
1757
|
};
|
|
1719
1758
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1720
1759
|
res.end(JSON.stringify({
|
|
@@ -1745,22 +1784,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
1745
1784
|
const s = JSON.parse(await fs.promises.readFile(summaryFile, "utf8"));
|
|
1746
1785
|
entry = { runId: d, timestamp: s.timestamp, status: s.status || (s.failed > 0 ? "failed" : "passed"), passed: s.passed || 0, failed: s.failed || 0, skipped: s.skipped || 0, total: s.total || 0, duration_ms: s.duration_ms || 0 };
|
|
1747
1786
|
} else {
|
|
1748
|
-
|
|
1787
|
+
let timestamp = null;
|
|
1788
|
+
try { const runMeta = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8")); timestamp = runMeta.timestamp; } catch {}
|
|
1789
|
+
if (!timestamp) { try { const stat = await fs.promises.stat(runDir); timestamp = stat.mtime.toISOString(); } catch {} }
|
|
1749
1790
|
const ents = await fs.promises.readdir(runDir);
|
|
1750
1791
|
const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
|
|
1751
1792
|
let failed = 0;
|
|
1752
1793
|
for (const td of testDirs) { if (await exists(path.join(runDir, td, "failure.json"))) failed++; }
|
|
1753
|
-
entry = { runId: d, timestamp
|
|
1794
|
+
entry = { runId: d, timestamp, status: failed > 0 ? "failed" : "passed", passed: testDirs.length - failed, failed, skipped: 0, total: testDirs.length, duration_ms: 0 };
|
|
1754
1795
|
}
|
|
1755
1796
|
// Detect suite from test_command
|
|
1756
1797
|
try {
|
|
1757
1798
|
const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
|
|
1758
1799
|
const cmd = r.test_command || "";
|
|
1759
|
-
if (cmd.includes("test
|
|
1800
|
+
if (cmd.includes("test:playwright") || cmd.includes("playwright test")) entry.suite = "playwright";
|
|
1801
|
+
else if (cmd.includes("test/e2e/") || cmd.includes("test:e2e")) entry.suite = "e2e";
|
|
1760
1802
|
else if (cmd.includes("test/integration/")) entry.suite = "integration";
|
|
1803
|
+
else if (cmd.includes("test:all")) entry.suite = "all";
|
|
1804
|
+
else if (cmd.includes("crew-cli") || cmd.includes("--prefix crew-cli")) entry.suite = "crew-cli";
|
|
1761
1805
|
else if (cmd.includes("test/unit/") && !cmd.includes("test/integration/")) entry.suite = "unit";
|
|
1762
1806
|
else { const fc = (cmd.match(/\.test\.mjs/g) || []).length; entry.suite = fc > 100 ? "all" : fc > 15 ? "unit" : "unknown"; }
|
|
1763
|
-
} catch {
|
|
1807
|
+
} catch {
|
|
1808
|
+
// No run.json — try to infer from directory contents
|
|
1809
|
+
try {
|
|
1810
|
+
const ents = await fs.promises.readdir(runDir);
|
|
1811
|
+
const first = ents.find(e => !e.endsWith(".json") && !e.startsWith(".")) || "";
|
|
1812
|
+
if (first.includes("test-e2e-")) entry.suite = "e2e";
|
|
1813
|
+
else if (first.includes("test-integration-")) entry.suite = "integration";
|
|
1814
|
+
else if (first.includes("test-unit-")) entry.suite = "unit";
|
|
1815
|
+
else if (first.includes("spec-")) entry.suite = "playwright";
|
|
1816
|
+
else entry.suite = "unknown";
|
|
1817
|
+
} catch { entry.suite = "unknown"; }
|
|
1818
|
+
}
|
|
1764
1819
|
history.push(entry);
|
|
1765
1820
|
} catch { /* skip */ }
|
|
1766
1821
|
}
|
|
@@ -1831,8 +1886,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1831
1886
|
req.on("data", (c) => (body += c));
|
|
1832
1887
|
req.on("end", async () => {
|
|
1833
1888
|
let suite = "test:unit";
|
|
1834
|
-
|
|
1835
|
-
|
|
1889
|
+
let singleFile = null;
|
|
1890
|
+
try {
|
|
1891
|
+
const parsed = JSON.parse(body);
|
|
1892
|
+
suite = parsed.suite || suite;
|
|
1893
|
+
singleFile = parsed.file || null;
|
|
1894
|
+
} catch { /* default */ }
|
|
1895
|
+
const allowed = ["test:unit", "test:integration", "test:e2e", "test:all", "test", "test:e2e:vibe", "test:playwright"];
|
|
1836
1896
|
if (!allowed.includes(suite)) {
|
|
1837
1897
|
res.writeHead(400, { "content-type": "application/json" });
|
|
1838
1898
|
res.end(JSON.stringify({ error: "Invalid suite: " + suite }));
|
|
@@ -1841,10 +1901,51 @@ const server = http.createServer(async (req, res) => {
|
|
|
1841
1901
|
const { spawn } = await import("node:child_process");
|
|
1842
1902
|
const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
|
|
1843
1903
|
const outputFile = path.join(CREWSWARM_DIR, "test-results", ".test-output.log");
|
|
1904
|
+
// Count total files for this suite so progress can show X/Y
|
|
1905
|
+
let files_total = 0;
|
|
1906
|
+
try {
|
|
1907
|
+
const testFileDir = path.join(CREWSWARM_DIR, "test");
|
|
1908
|
+
const testsE2eDir = path.join(CREWSWARM_DIR, "tests", "e2e");
|
|
1909
|
+
const crewCliTestDir = path.join(CREWSWARM_DIR, "crew-cli", "tests");
|
|
1910
|
+
const crewCliTestDir2 = path.join(CREWSWARM_DIR, "crew-cli", "test");
|
|
1911
|
+
const suiteKey = suite.replace("test:", "");
|
|
1912
|
+
if (suiteKey === "unit") files_total = (await fs.promises.readdir(path.join(testFileDir, "unit"))).filter(f => f.endsWith(".test.mjs")).length;
|
|
1913
|
+
else if (suiteKey === "integration") files_total = (await fs.promises.readdir(path.join(testFileDir, "integration"))).filter(f => f.endsWith(".test.mjs")).length;
|
|
1914
|
+
else if (suiteKey === "e2e") files_total = (await fs.promises.readdir(path.join(testFileDir, "e2e"))).filter(f => f.endsWith(".test.mjs")).length;
|
|
1915
|
+
else if (suiteKey === "playwright") files_total = (await fs.promises.readdir(testsE2eDir)).filter(f => f.endsWith(".spec.js")).length;
|
|
1916
|
+
else if (suite === "test") { // crew-cli
|
|
1917
|
+
const count = async (d) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(/\.test\./)).length; } catch { return 0; } };
|
|
1918
|
+
files_total = await count(path.join(crewCliTestDir, "unit")) + await count(crewCliTestDir) + await count(crewCliTestDir2);
|
|
1919
|
+
} else if (suiteKey === "all") {
|
|
1920
|
+
// Sum all suites
|
|
1921
|
+
const count = async (d, p) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(p)).length; } catch { return 0; } };
|
|
1922
|
+
const countR = async (d) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(/\.test\./)).length; } catch { return 0; } };
|
|
1923
|
+
files_total = await count(path.join(testFileDir, "unit"), /\.test\.mjs$/)
|
|
1924
|
+
+ await count(path.join(testFileDir, "integration"), /\.test\.mjs$/)
|
|
1925
|
+
+ await count(path.join(testFileDir, "e2e"), /\.test\.mjs$/)
|
|
1926
|
+
+ await count(testsE2eDir, /\.spec\.js$/)
|
|
1927
|
+
+ await countR(path.join(crewCliTestDir, "unit")) + await countR(crewCliTestDir) + await countR(crewCliTestDir2);
|
|
1928
|
+
}
|
|
1929
|
+
} catch {}
|
|
1844
1930
|
// Write initial progress
|
|
1845
|
-
await fs.promises.writeFile(progressFile, JSON.stringify({ suite, running: true, pid: 0, started: Date.now(), passed: 0, failed: 0, skipped: 0, files_done: 0, current_file: "" }));
|
|
1931
|
+
await fs.promises.writeFile(progressFile, JSON.stringify({ suite, running: true, pid: 0, started: Date.now(), passed: 0, failed: 0, skipped: 0, files_done: 0, files_total, current_file: singleFile || "" }));
|
|
1932
|
+
// Clean up any stale progress from a previous interrupted run
|
|
1933
|
+
const staleProgress = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
|
|
1934
|
+
try {
|
|
1935
|
+
const prev = JSON.parse(fs.readFileSync(staleProgress, "utf8"));
|
|
1936
|
+
if (prev.running && prev.pid) {
|
|
1937
|
+
try { process.kill(prev.pid, 0); process.kill(prev.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
1938
|
+
}
|
|
1939
|
+
} catch { /* no stale progress */ }
|
|
1940
|
+
let child;
|
|
1846
1941
|
const outFd = fs.openSync(outputFile, "w");
|
|
1847
|
-
|
|
1942
|
+
if (singleFile) {
|
|
1943
|
+
// Single-file run: use node --test directly on the file
|
|
1944
|
+
const absFile = path.isAbsolute(singleFile) ? singleFile : path.join(CREWSWARM_DIR, singleFile);
|
|
1945
|
+
child = spawn("node", ["--test", "--test-reporter=./scripts/test-reporter.mjs", absFile], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
|
|
1946
|
+
} else {
|
|
1947
|
+
child = spawn("npm", ["run", suite], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
|
|
1948
|
+
}
|
|
1848
1949
|
child.unref();
|
|
1849
1950
|
fs.closeSync(outFd);
|
|
1850
1951
|
// Update progress by tailing the output file
|
|
@@ -1862,7 +1963,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1862
1963
|
current_file = line.trim();
|
|
1863
1964
|
}
|
|
1864
1965
|
}
|
|
1865
|
-
|
|
1966
|
+
const prev = JSON.parse(fs.readFileSync(progressFile, "utf8"));
|
|
1967
|
+
fs.writeFileSync(progressFile, JSON.stringify({ suite, running: true, pid: child.pid, started: prev.started, passed, failed, skipped, files_done, files_total: prev.files_total || 0, current_file }));
|
|
1866
1968
|
} catch { /* file may not exist yet */ }
|
|
1867
1969
|
}, 2000);
|
|
1868
1970
|
child.on("exit", (code) => {
|
|
@@ -1885,6 +1987,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
1885
1987
|
});
|
|
1886
1988
|
return;
|
|
1887
1989
|
}
|
|
1990
|
+
if (url.pathname === "/api/tests/stop" && req.method === "POST") {
|
|
1991
|
+
const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
|
|
1992
|
+
try {
|
|
1993
|
+
const data = JSON.parse(await fs.promises.readFile(progressFile, "utf8"));
|
|
1994
|
+
if (data.running && data.pid) {
|
|
1995
|
+
try { process.kill(data.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
1996
|
+
// Also kill child processes (node --test spawns sub-processes)
|
|
1997
|
+
try { process.kill(-data.pid, "SIGTERM"); } catch { /* no process group */ }
|
|
1998
|
+
data.running = false;
|
|
1999
|
+
data.stopped = true;
|
|
2000
|
+
data.finished = Date.now();
|
|
2001
|
+
await fs.promises.writeFile(progressFile, JSON.stringify(data));
|
|
2002
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2003
|
+
res.end(JSON.stringify({ stopped: true, pid: data.pid }));
|
|
2004
|
+
} else {
|
|
2005
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2006
|
+
res.end(JSON.stringify({ stopped: false, reason: "no running test" }));
|
|
2007
|
+
}
|
|
2008
|
+
} catch {
|
|
2009
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2010
|
+
res.end(JSON.stringify({ stopped: false, reason: "no progress file" }));
|
|
2011
|
+
}
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
1888
2014
|
if (url.pathname === "/api/tests/progress" && req.method === "GET") {
|
|
1889
2015
|
const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
|
|
1890
2016
|
try {
|
|
@@ -1898,6 +2024,173 @@ const server = http.createServer(async (req, res) => {
|
|
|
1898
2024
|
return;
|
|
1899
2025
|
}
|
|
1900
2026
|
|
|
2027
|
+
// ── GET /api/tests/stale — files changed since last test run ────────────
|
|
2028
|
+
if (url.pathname === "/api/tests/stale" && req.method === "GET") {
|
|
2029
|
+
const resultsDir = path.join(CREWSWARM_DIR, "test-results");
|
|
2030
|
+
const logPath = path.join(resultsDir, "test-log.jsonl");
|
|
2031
|
+
try {
|
|
2032
|
+
// Read last-run fingerprints from test-log.jsonl
|
|
2033
|
+
const fingerprintByFile = new Map();
|
|
2034
|
+
try {
|
|
2035
|
+
const lines = (await fs.promises.readFile(logPath, "utf8")).split("\n").filter(Boolean);
|
|
2036
|
+
for (const line of lines) {
|
|
2037
|
+
try {
|
|
2038
|
+
const entry = JSON.parse(line);
|
|
2039
|
+
if (entry.file && entry.file_fingerprint?.mtime) {
|
|
2040
|
+
// Keep the most recent entry per file
|
|
2041
|
+
fingerprintByFile.set(entry.file, entry.file_fingerprint);
|
|
2042
|
+
}
|
|
2043
|
+
} catch {}
|
|
2044
|
+
}
|
|
2045
|
+
} catch {}
|
|
2046
|
+
const stale = [];
|
|
2047
|
+
for (const [filePath, fp] of fingerprintByFile) {
|
|
2048
|
+
try {
|
|
2049
|
+
const stat = await fs.promises.stat(filePath);
|
|
2050
|
+
const currentMtime = stat.mtime.toISOString();
|
|
2051
|
+
if (currentMtime > fp.mtime) {
|
|
2052
|
+
stale.push({
|
|
2053
|
+
file: filePath.replace(CREWSWARM_DIR + "/", ""),
|
|
2054
|
+
lastRun: fp.mtime,
|
|
2055
|
+
lastModified: currentMtime,
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
} catch {}
|
|
2059
|
+
}
|
|
2060
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2061
|
+
res.end(JSON.stringify({ stale }));
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2064
|
+
res.end(JSON.stringify({ stale: [], error: e.message }));
|
|
2065
|
+
}
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// ── GET /api/tests/stream — SSE stream of running test output ───────────
|
|
2070
|
+
if (url.pathname === "/api/tests/stream" && req.method === "GET") {
|
|
2071
|
+
const outputFile = path.join(CREWSWARM_DIR, "test-results", ".test-output.log");
|
|
2072
|
+
res.writeHead(200, {
|
|
2073
|
+
"content-type": "text/event-stream",
|
|
2074
|
+
"cache-control": "no-cache",
|
|
2075
|
+
"connection": "keep-alive",
|
|
2076
|
+
"access-control-allow-origin": "*",
|
|
2077
|
+
});
|
|
2078
|
+
let lastSize = 0;
|
|
2079
|
+
// Send existing content first
|
|
2080
|
+
try {
|
|
2081
|
+
const content = await fs.promises.readFile(outputFile, "utf8");
|
|
2082
|
+
if (content) {
|
|
2083
|
+
res.write("data: " + JSON.stringify({ text: content, reset: true }) + "\n\n");
|
|
2084
|
+
lastSize = Buffer.byteLength(content, "utf8");
|
|
2085
|
+
}
|
|
2086
|
+
} catch {}
|
|
2087
|
+
const streamInterval = setInterval(async () => {
|
|
2088
|
+
try {
|
|
2089
|
+
const stat = await fs.promises.stat(outputFile);
|
|
2090
|
+
if (stat.size > lastSize) {
|
|
2091
|
+
const fd = await fs.promises.open(outputFile, "r");
|
|
2092
|
+
const newBytes = stat.size - lastSize;
|
|
2093
|
+
const buf = Buffer.alloc(newBytes);
|
|
2094
|
+
await fd.read(buf, 0, newBytes, lastSize);
|
|
2095
|
+
await fd.close();
|
|
2096
|
+
const text = buf.toString("utf8");
|
|
2097
|
+
lastSize = stat.size;
|
|
2098
|
+
res.write("data: " + JSON.stringify({ text }) + "\n\n");
|
|
2099
|
+
}
|
|
2100
|
+
// Check if done
|
|
2101
|
+
const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
|
|
2102
|
+
try {
|
|
2103
|
+
const prog = JSON.parse(await fs.promises.readFile(progressFile, "utf8"));
|
|
2104
|
+
if (!prog.running && prog.finished) {
|
|
2105
|
+
res.write("data: " + JSON.stringify({ done: true }) + "\n\n");
|
|
2106
|
+
clearInterval(streamInterval);
|
|
2107
|
+
res.end();
|
|
2108
|
+
}
|
|
2109
|
+
} catch {}
|
|
2110
|
+
} catch {}
|
|
2111
|
+
}, 500);
|
|
2112
|
+
req.on("close", () => { clearInterval(streamInterval); });
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// ── GET /api/tests/screenshot — serve a Playwright failure screenshot ────
|
|
2117
|
+
if (url.pathname === "/api/tests/screenshot" && req.method === "GET") {
|
|
2118
|
+
const relPath = url.searchParams.get("path");
|
|
2119
|
+
if (!relPath) { res.writeHead(400); res.end("Missing path"); return; }
|
|
2120
|
+
// Security: only allow paths within test-results/
|
|
2121
|
+
const safePath = path.normalize(relPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
2122
|
+
const absPath = path.join(CREWSWARM_DIR, "test-results", safePath);
|
|
2123
|
+
if (!absPath.startsWith(path.join(CREWSWARM_DIR, "test-results"))) {
|
|
2124
|
+
res.writeHead(403); res.end("Forbidden"); return;
|
|
2125
|
+
}
|
|
2126
|
+
try {
|
|
2127
|
+
const stat = await fs.promises.stat(absPath);
|
|
2128
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
2129
|
+
const mimeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
2130
|
+
const mime = mimeMap[ext] || "application/octet-stream";
|
|
2131
|
+
res.writeHead(200, { "content-type": mime, "content-length": stat.size, "cache-control": "max-age=300" });
|
|
2132
|
+
fs.createReadStream(absPath).pipe(res);
|
|
2133
|
+
} catch {
|
|
2134
|
+
res.writeHead(404); res.end("Screenshot not found");
|
|
2135
|
+
}
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// ── GET /api/tests/coverage-map — source files vs test coverage ──────────
|
|
2140
|
+
if (url.pathname === "/api/tests/coverage-map" && req.method === "GET") {
|
|
2141
|
+
try {
|
|
2142
|
+
const libDir = path.join(CREWSWARM_DIR, "lib");
|
|
2143
|
+
const crewCliSrcDir = path.join(CREWSWARM_DIR, "crew-cli", "src");
|
|
2144
|
+
const unitTestDir = path.join(CREWSWARM_DIR, "test", "unit");
|
|
2145
|
+
const crewCliTestDir = path.join(CREWSWARM_DIR, "crew-cli", "tests", "unit");
|
|
2146
|
+
|
|
2147
|
+
// Collect test file basenames (strip extension)
|
|
2148
|
+
const testBases = new Set();
|
|
2149
|
+
async function collectTestBases(dir) {
|
|
2150
|
+
try {
|
|
2151
|
+
for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
2152
|
+
if (ent.isDirectory()) { await collectTestBases(path.join(dir, ent.name)); continue; }
|
|
2153
|
+
if (ent.name.match(/\.test\.(mjs|js|ts)$/)) {
|
|
2154
|
+
// e.g. crew-judge.test.mjs -> crew-judge
|
|
2155
|
+
testBases.add(ent.name.replace(/\.test\.(mjs|js|ts)$/, ""));
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
} catch {}
|
|
2159
|
+
}
|
|
2160
|
+
await collectTestBases(unitTestDir);
|
|
2161
|
+
await collectTestBases(crewCliTestDir);
|
|
2162
|
+
|
|
2163
|
+
// Collect source files and check coverage
|
|
2164
|
+
const covered = [];
|
|
2165
|
+
const uncovered = [];
|
|
2166
|
+
async function scanSourceDir(dir, prefix) {
|
|
2167
|
+
try {
|
|
2168
|
+
for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
2169
|
+
const fullPath = path.join(dir, ent.name);
|
|
2170
|
+
const relPath = prefix + "/" + ent.name;
|
|
2171
|
+
if (ent.isDirectory()) { await scanSourceDir(fullPath, relPath); continue; }
|
|
2172
|
+
if (!ent.name.match(/\.(mjs|js|ts)$/) || ent.name.includes(".d.ts") || ent.name.includes(".test.")) continue;
|
|
2173
|
+
const base = ent.name.replace(/\.(mjs|js|ts)$/, "");
|
|
2174
|
+
// Check if any test file matches by base name (fuzzy: contains or equals)
|
|
2175
|
+
const hasCoverage = [...testBases].some(tb => tb === base || tb.includes(base) || base.includes(tb));
|
|
2176
|
+
const entry = { file: relPath, base };
|
|
2177
|
+
if (hasCoverage) covered.push(entry);
|
|
2178
|
+
else uncovered.push(entry);
|
|
2179
|
+
}
|
|
2180
|
+
} catch {}
|
|
2181
|
+
}
|
|
2182
|
+
await scanSourceDir(libDir, "lib");
|
|
2183
|
+
await scanSourceDir(crewCliSrcDir, "crew-cli/src");
|
|
2184
|
+
|
|
2185
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2186
|
+
res.end(JSON.stringify({ covered, uncovered, totalCovered: covered.length, totalUncovered: uncovered.length }));
|
|
2187
|
+
} catch (e) {
|
|
2188
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2189
|
+
res.end(JSON.stringify({ covered: [], uncovered: [], error: e.message }));
|
|
2190
|
+
}
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
1901
2194
|
// ── First-run detection ──────────────────────────────────────────────────
|
|
1902
2195
|
if (url.pathname === "/api/first-run-status" && req.method === "GET") {
|
|
1903
2196
|
const cfg = readSwarmConfigSafe();
|
|
@@ -4517,8 +4810,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
4517
4810
|
if (!providers["anthropic-oauth"]) {
|
|
4518
4811
|
try {
|
|
4519
4812
|
const { execFileSync } = await import("node:child_process");
|
|
4520
|
-
const
|
|
4521
|
-
for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
|
|
4813
|
+
for (const acct of getKeychainAccountCandidates()) {
|
|
4522
4814
|
try {
|
|
4523
4815
|
const raw = execFileSync("security", [
|
|
4524
4816
|
"find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
|
|
@@ -4553,8 +4845,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
4553
4845
|
if (!token) {
|
|
4554
4846
|
if (providerId === "anthropic-oauth") {
|
|
4555
4847
|
const { execFileSync } = await import("node:child_process");
|
|
4556
|
-
const
|
|
4557
|
-
for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
|
|
4848
|
+
for (const acct of getKeychainAccountCandidates()) {
|
|
4558
4849
|
try {
|
|
4559
4850
|
const raw = execFileSync("security", [
|
|
4560
4851
|
"find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
|
|
@@ -4683,8 +4974,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
4683
4974
|
let token = getOAuthTokenCached(providerId);
|
|
4684
4975
|
if (!token && providerId === "anthropic-oauth") {
|
|
4685
4976
|
const { execFileSync } = await import("node:child_process");
|
|
4686
|
-
const
|
|
4687
|
-
for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
|
|
4977
|
+
for (const acct of getKeychainAccountCandidates()) {
|
|
4688
4978
|
try {
|
|
4689
4979
|
const raw = execFileSync("security", [
|
|
4690
4980
|
"find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
|
|
@@ -7643,6 +7933,86 @@ ORDER BY day DESC, cost DESC;`;
|
|
|
7643
7933
|
}
|
|
7644
7934
|
return;
|
|
7645
7935
|
}
|
|
7936
|
+
// ── crew-cli cost stats ──────────────────────────────────────────────────
|
|
7937
|
+
if (url.pathname === "/api/crew-cli-stats" && req.method === "GET") {
|
|
7938
|
+
const days = Number(url.searchParams.get("days") || "14");
|
|
7939
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
|
7940
|
+
try {
|
|
7941
|
+
// Scan known project directories for .crew/cost.json files
|
|
7942
|
+
const searchDirs = new Set();
|
|
7943
|
+
// 1. opencodeProject from config
|
|
7944
|
+
try {
|
|
7945
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".crewswarm", "crewswarm.json"), "utf8"));
|
|
7946
|
+
if (cfg.opencodeProject) searchDirs.add(cfg.opencodeProject.replace(/\/+$/, ""));
|
|
7947
|
+
} catch {}
|
|
7948
|
+
// 2. Registered projects
|
|
7949
|
+
try {
|
|
7950
|
+
const projFile = path.join(os.homedir(), ".crewswarm", "projects.json");
|
|
7951
|
+
const projs = JSON.parse(fs.readFileSync(projFile, "utf8"));
|
|
7952
|
+
for (const p of (projs.projects || projs || [])) {
|
|
7953
|
+
if (p.outputDir) searchDirs.add(p.outputDir.replace(/\/+$/, ""));
|
|
7954
|
+
}
|
|
7955
|
+
} catch {}
|
|
7956
|
+
// 3. Home .crew dir
|
|
7957
|
+
searchDirs.add(os.homedir());
|
|
7958
|
+
|
|
7959
|
+
const allEntries = [];
|
|
7960
|
+
for (const dir of searchDirs) {
|
|
7961
|
+
const costFile = path.join(dir, ".crew", "cost.json");
|
|
7962
|
+
try {
|
|
7963
|
+
const raw = JSON.parse(fs.readFileSync(costFile, "utf8"));
|
|
7964
|
+
for (const entry of (raw.entries || [])) {
|
|
7965
|
+
if (!entry.timestamp) continue;
|
|
7966
|
+
const day = entry.timestamp.slice(0, 10);
|
|
7967
|
+
if (day >= cutoff) {
|
|
7968
|
+
allEntries.push({ ...entry, day, project: dir });
|
|
7969
|
+
}
|
|
7970
|
+
}
|
|
7971
|
+
} catch {}
|
|
7972
|
+
}
|
|
7973
|
+
|
|
7974
|
+
// Roll up by day
|
|
7975
|
+
const byDay = {};
|
|
7976
|
+
let totalCost = 0;
|
|
7977
|
+
let totalCalls = 0;
|
|
7978
|
+
let totalPromptTokens = 0;
|
|
7979
|
+
let totalCompletionTokens = 0;
|
|
7980
|
+
for (const e of allEntries) {
|
|
7981
|
+
if (!byDay[e.day]) byDay[e.day] = { cost: 0, calls: 0, prompt_tokens: 0, completion_tokens: 0, byModel: {} };
|
|
7982
|
+
const d = byDay[e.day];
|
|
7983
|
+
const usd = Number(e.usd || 0);
|
|
7984
|
+
d.cost += usd;
|
|
7985
|
+
d.calls += 1;
|
|
7986
|
+
d.prompt_tokens += Number(e.promptTokens || 0);
|
|
7987
|
+
d.completion_tokens += Number(e.completionTokens || 0);
|
|
7988
|
+
const model = e.model || "unknown";
|
|
7989
|
+
if (!d.byModel[model]) d.byModel[model] = { cost: 0, calls: 0, prompt_tokens: 0, completion_tokens: 0 };
|
|
7990
|
+
d.byModel[model].cost += usd;
|
|
7991
|
+
d.byModel[model].calls += 1;
|
|
7992
|
+
d.byModel[model].prompt_tokens += Number(e.promptTokens || 0);
|
|
7993
|
+
d.byModel[model].completion_tokens += Number(e.completionTokens || 0);
|
|
7994
|
+
totalCost += usd;
|
|
7995
|
+
totalCalls += 1;
|
|
7996
|
+
totalPromptTokens += Number(e.promptTokens || 0);
|
|
7997
|
+
totalCompletionTokens += Number(e.completionTokens || 0);
|
|
7998
|
+
}
|
|
7999
|
+
|
|
8000
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
8001
|
+
res.end(JSON.stringify({
|
|
8002
|
+
ok: true,
|
|
8003
|
+
totalCost,
|
|
8004
|
+
totalCalls,
|
|
8005
|
+
totalPromptTokens,
|
|
8006
|
+
totalCompletionTokens,
|
|
8007
|
+
projects: [...searchDirs],
|
|
8008
|
+
byDay,
|
|
8009
|
+
}));
|
|
8010
|
+
} catch (e) {
|
|
8011
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
8012
|
+
res.end(JSON.stringify({ ok: false, error: e.message, byDay: {} }));
|
|
8013
|
+
}
|
|
8014
|
+
return;
|
|
8015
|
+
}
|
|
7646
8016
|
// ── OpenCode models API ──────────────────────────────────────────────────
|
|
7647
8017
|
if (url.pathname === "/api/opencode-models" && req.method === "GET") {
|
|
7648
8018
|
let models = [];
|