codecartographer-pi 0.19.5 → 0.20.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/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/templates/gitignore +55 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +351 -68
- package/dist/core/completion.js +95 -26
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +115 -107
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +30 -2
- package/dist/core/status.js +54 -8
- package/dist/core/synthesis.js +5 -2
- package/dist/core/usage.d.ts +8 -0
- package/dist/core/usage.js +35 -7
- package/dist/core/utils.d.ts +32 -5
- package/dist/core/utils.js +81 -19
- package/dist/core/workspace.d.ts +99 -18
- package/dist/core/workspace.js +275 -36
- package/dist/core/yaml.js +173 -14
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.js +10 -6
- package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -157
- package/dist/extensions/codecarto/index.js +73 -21
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +111 -50
- package/package.json +3 -2
package/dist/core/broadside.js
CHANGED
|
@@ -31,11 +31,12 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
-
import { mkdir, readFile, readdir,
|
|
34
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
35
35
|
import { execFile } from "node:child_process";
|
|
36
36
|
import { promisify } from "node:util";
|
|
37
37
|
import { join, relative } from "node:path";
|
|
38
|
-
import { pathExists, sleep } from "./utils.js";
|
|
38
|
+
import { atomicWriteFile, pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
|
|
39
40
|
import { acquireLock } from "./status.js";
|
|
40
41
|
import { loadYamlFile } from "./yaml.js";
|
|
41
42
|
import { packagedWorkspaceDir } from "./workspace.js";
|
|
@@ -102,6 +103,23 @@ export const BROADSIDE_MIN_REASONING_TOKENS = 512;
|
|
|
102
103
|
export function defaultReasoningFor(maxTokens) {
|
|
103
104
|
return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
|
|
108
|
+
* lookup rather than swallowed into "could not price" or a silent built-in
|
|
109
|
+
* fallback: a run that cannot authenticate cannot submit either, and the
|
|
110
|
+
* message that reaches the user has to say so (#251).
|
|
111
|
+
*/
|
|
112
|
+
export class BroadsideAuthError extends Error {
|
|
113
|
+
httpStatus;
|
|
114
|
+
detail;
|
|
115
|
+
constructor(httpStatus, detail) {
|
|
116
|
+
super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
|
|
117
|
+
"Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
|
|
118
|
+
this.name = "BroadsideAuthError";
|
|
119
|
+
this.httpStatus = httpStatus;
|
|
120
|
+
this.detail = detail;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
105
123
|
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
106
124
|
export class BroadsideCancelledError extends Error {
|
|
107
125
|
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
@@ -799,14 +817,23 @@ const SKIP_FILE_EXTENSIONS = new Set([
|
|
|
799
817
|
".model",
|
|
800
818
|
".bpe",
|
|
801
819
|
]);
|
|
820
|
+
/**
|
|
821
|
+
* Manifest files and the languages each one can mean. `package.json` covers
|
|
822
|
+
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
823
|
+
* counting its source files, not from the manifest.
|
|
824
|
+
*/
|
|
802
825
|
const MANIFEST_CANDIDATES = [
|
|
803
|
-
["go.mod", "go"],
|
|
804
|
-
["package.json", "typescript"],
|
|
805
|
-
["Cargo.toml", "rust"],
|
|
806
|
-
["pyproject.toml", "python"],
|
|
807
|
-
["setup.py", "python"],
|
|
808
|
-
["requirements.txt", "python"],
|
|
826
|
+
["go.mod", ["go"]],
|
|
827
|
+
["package.json", ["typescript", "javascript"]],
|
|
828
|
+
["Cargo.toml", ["rust"]],
|
|
829
|
+
["pyproject.toml", ["python"]],
|
|
830
|
+
["setup.py", ["python"]],
|
|
831
|
+
["requirements.txt", ["python"]],
|
|
809
832
|
];
|
|
833
|
+
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
834
|
+
export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
|
|
835
|
+
/** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
|
|
836
|
+
const REPO_INFO_FILE_CAP = 20_000;
|
|
810
837
|
const SOURCE_SPECS = {
|
|
811
838
|
go: { glob: "**/*.go", exts: [".go"] },
|
|
812
839
|
python: { glob: "**/*.py", exts: [".py"] },
|
|
@@ -814,16 +841,26 @@ const SOURCE_SPECS = {
|
|
|
814
841
|
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
815
842
|
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
816
843
|
};
|
|
844
|
+
/**
|
|
845
|
+
* The files a run scans, and where they came from. Contents are always read
|
|
846
|
+
* from the working tree, so the list is the working tree's too: tracked files
|
|
847
|
+
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
848
|
+
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
849
|
+
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
850
|
+
* A target that is not a git repository gets a bounded walk.
|
|
851
|
+
*/
|
|
817
852
|
async function listRepoFiles(targetDir) {
|
|
818
|
-
// git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
|
|
819
853
|
try {
|
|
820
|
-
const
|
|
854
|
+
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
|
|
855
|
+
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
821
856
|
maxBuffer: 64 * 1024 * 1024,
|
|
822
857
|
});
|
|
823
|
-
|
|
858
|
+
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
859
|
+
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
860
|
+
return { files, snapshot: "working-tree" };
|
|
824
861
|
}
|
|
825
862
|
catch {
|
|
826
|
-
return walkFiles(targetDir, targetDir, 0, 30_000);
|
|
863
|
+
return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
|
|
827
864
|
}
|
|
828
865
|
}
|
|
829
866
|
async function gitHead(targetDir) {
|
|
@@ -892,25 +929,65 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
892
929
|
}
|
|
893
930
|
return out;
|
|
894
931
|
}
|
|
895
|
-
function
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
932
|
+
function sourceFileCount(language, fileCounts) {
|
|
933
|
+
return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* The language the lenses scan as. The manifests present name the candidates
|
|
937
|
+
* (all of them, not the first one found: a Python service with a
|
|
938
|
+
* `package.json` for its docs tooling is not a TypeScript repository), and
|
|
939
|
+
* among candidates the one with the most source files wins; without a
|
|
940
|
+
* manifest, the language with the most source files; without any source
|
|
941
|
+
* file, `unknown` — which submit refuses rather than scanning nothing and
|
|
942
|
+
* paying for it (#250). Ties keep manifest order.
|
|
943
|
+
*/
|
|
944
|
+
function detectLanguage(fileCounts, manifestPaths) {
|
|
945
|
+
const candidates = [];
|
|
946
|
+
for (const [candidate, languages] of MANIFEST_CANDIDATES) {
|
|
947
|
+
if (!manifestPaths.includes(candidate))
|
|
948
|
+
continue;
|
|
949
|
+
for (const language of languages)
|
|
950
|
+
if (!candidates.includes(language))
|
|
951
|
+
candidates.push(language);
|
|
952
|
+
}
|
|
953
|
+
const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
|
|
954
|
+
let best = null;
|
|
955
|
+
let bestCount = -1;
|
|
956
|
+
for (const language of pool) {
|
|
957
|
+
const count = sourceFileCount(language, fileCounts);
|
|
958
|
+
if (count > bestCount) {
|
|
959
|
+
best = language;
|
|
960
|
+
bestCount = count;
|
|
900
961
|
}
|
|
901
962
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
javascript: fileCounts[".js"] ?? 0,
|
|
908
|
-
};
|
|
909
|
-
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
910
|
-
return best && best[1] > 0 ? best[0] : "unknown";
|
|
963
|
+
if (bestCount > 0)
|
|
964
|
+
return best;
|
|
965
|
+
// A manifest with no source files behind it still names the language;
|
|
966
|
+
// submit reports the empty count. No manifest and no source: unknown.
|
|
967
|
+
return candidates[0] ?? "unknown";
|
|
911
968
|
}
|
|
912
|
-
|
|
913
|
-
|
|
969
|
+
/** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
|
|
970
|
+
function capForPrompt(content, cap) {
|
|
971
|
+
if (content.length <= cap)
|
|
972
|
+
return content;
|
|
973
|
+
return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
|
|
974
|
+
}
|
|
975
|
+
export async function collectRepoInfo(targetDir, opts = {}) {
|
|
976
|
+
const redact = opts.redact ?? true;
|
|
977
|
+
const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
|
|
978
|
+
// Named credential stores are out of every lens (isSlurpable); listed here
|
|
979
|
+
// so the submit report can say so.
|
|
980
|
+
const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
|
|
981
|
+
let redactedValues = 0;
|
|
982
|
+
// The entry point, manifest, and README ride in the architecture prompt
|
|
983
|
+
// as text, so they get the same pass the slices do (#252).
|
|
984
|
+
const clean = (text) => {
|
|
985
|
+
if (!redact)
|
|
986
|
+
return text;
|
|
987
|
+
const redaction = redactSecrets(text);
|
|
988
|
+
redactedValues += redaction.count;
|
|
989
|
+
return redaction.text;
|
|
990
|
+
};
|
|
914
991
|
const fileCounts = {};
|
|
915
992
|
for (const f of allFiles) {
|
|
916
993
|
const slash = f.lastIndexOf("/");
|
|
@@ -923,25 +1000,37 @@ export async function collectRepoInfo(targetDir) {
|
|
|
923
1000
|
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
924
1001
|
sortedCounts[ext] = n;
|
|
925
1002
|
}
|
|
926
|
-
|
|
1003
|
+
// Every manifest present counts toward language detection; the first one
|
|
1004
|
+
// found is the one the architecture prompt shows.
|
|
1005
|
+
const manifestPaths = [];
|
|
927
1006
|
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1007
|
+
if (await pathExists(join(targetDir, candidate)))
|
|
1008
|
+
manifestPaths.push(candidate);
|
|
1009
|
+
}
|
|
1010
|
+
const language = detectLanguage(sortedCounts, manifestPaths);
|
|
1011
|
+
// Show the manifest that belongs to the detected language when there is
|
|
1012
|
+
// one, so a polyglot repo's prompt does not open with the other stack's file.
|
|
1013
|
+
const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
|
|
1014
|
+
?? manifestPaths[0]
|
|
1015
|
+
?? null;
|
|
1016
|
+
let manifest = null;
|
|
1017
|
+
if (manifestPath) {
|
|
1018
|
+
try {
|
|
1019
|
+
manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
manifest = null;
|
|
937
1023
|
}
|
|
938
1024
|
}
|
|
1025
|
+
// Read whole and unbounded before, and then estimated at a flat 6,000
|
|
1026
|
+
// chars: a large entry point shipped in full while the cap was checked
|
|
1027
|
+
// against a number that had nothing to do with it (#249).
|
|
939
1028
|
let mainFile = "";
|
|
940
|
-
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
|
|
1029
|
+
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
|
|
941
1030
|
const p = join(targetDir, candidate);
|
|
942
1031
|
if (await pathExists(p)) {
|
|
943
1032
|
try {
|
|
944
|
-
mainFile = await readFile(p, "utf8");
|
|
1033
|
+
mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
|
|
945
1034
|
}
|
|
946
1035
|
catch {
|
|
947
1036
|
mainFile = "";
|
|
@@ -953,16 +1042,18 @@ export async function collectRepoInfo(targetDir) {
|
|
|
953
1042
|
const readmePath = join(targetDir, "README.md");
|
|
954
1043
|
if (await pathExists(readmePath)) {
|
|
955
1044
|
try {
|
|
956
|
-
readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
|
|
1045
|
+
readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
|
|
957
1046
|
}
|
|
958
1047
|
catch {
|
|
959
1048
|
readmeFirst = "";
|
|
960
1049
|
}
|
|
961
1050
|
}
|
|
962
1051
|
const fileTree = buildFileTree(allFiles);
|
|
963
|
-
|
|
964
|
-
|
|
1052
|
+
// An unknown language used to fall through to Go's globs, so the code
|
|
1053
|
+
// lenses matched nothing and the run paid for empty batches (#250).
|
|
1054
|
+
const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
|
|
965
1055
|
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
1056
|
+
const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
|
|
966
1057
|
return {
|
|
967
1058
|
name,
|
|
968
1059
|
path: targetDir,
|
|
@@ -974,6 +1065,10 @@ export async function collectRepoInfo(targetDir) {
|
|
|
974
1065
|
fileCounts: sortedCounts,
|
|
975
1066
|
sourceGlob: sourceSpec.glob,
|
|
976
1067
|
sourceExts: sourceSpec.exts,
|
|
1068
|
+
sourceFileCount: sourceFiles,
|
|
1069
|
+
snapshot,
|
|
1070
|
+
secretFilesSkipped,
|
|
1071
|
+
redactedValues,
|
|
977
1072
|
};
|
|
978
1073
|
}
|
|
979
1074
|
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
@@ -1034,6 +1129,9 @@ function matchesAnyGlob(path, globs) {
|
|
|
1034
1129
|
return false;
|
|
1035
1130
|
}
|
|
1036
1131
|
function isSlurpable(relPath) {
|
|
1132
|
+
// A credential store is never a lens input, whatever its globs say (#252).
|
|
1133
|
+
if (isSecretFile(relPath))
|
|
1134
|
+
return false;
|
|
1037
1135
|
const segments = relPath.split("/");
|
|
1038
1136
|
for (const seg of segments) {
|
|
1039
1137
|
if (SKIP_DIR_NAMES.has(seg))
|
|
@@ -1070,7 +1168,7 @@ function resolveSliceMode(lens, files, totalChars) {
|
|
|
1070
1168
|
return totalChars > lens.maxChars ? "directory" : "none";
|
|
1071
1169
|
}
|
|
1072
1170
|
function collectLensFiles(allFiles, lens, info) {
|
|
1073
|
-
const globs = lens.globsFor(info);
|
|
1171
|
+
const globs = lens.globsFor(info).filter(Boolean);
|
|
1074
1172
|
if (globs.length === 0)
|
|
1075
1173
|
return [];
|
|
1076
1174
|
const out = [];
|
|
@@ -1085,13 +1183,15 @@ function collectLensFiles(allFiles, lens, info) {
|
|
|
1085
1183
|
}
|
|
1086
1184
|
return out;
|
|
1087
1185
|
}
|
|
1088
|
-
async function slurpFileList(targetDir, files, maxChars) {
|
|
1186
|
+
async function slurpFileList(targetDir, files, maxChars, redact = true) {
|
|
1089
1187
|
const slices = [];
|
|
1090
1188
|
let currentModule = "";
|
|
1091
1189
|
let parts = [];
|
|
1092
1190
|
let running = 0;
|
|
1093
1191
|
let fileCount = 0;
|
|
1094
1192
|
let filePaths = [];
|
|
1193
|
+
let redactedValues = 0;
|
|
1194
|
+
let redactedFiles = [];
|
|
1095
1195
|
const flush = () => {
|
|
1096
1196
|
if (parts.length === 0)
|
|
1097
1197
|
return;
|
|
@@ -1101,20 +1201,38 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1101
1201
|
fileCount,
|
|
1102
1202
|
chars: running,
|
|
1103
1203
|
files: filePaths,
|
|
1204
|
+
redactedValues,
|
|
1205
|
+
redactedFiles,
|
|
1104
1206
|
});
|
|
1105
1207
|
parts = [];
|
|
1106
1208
|
running = 0;
|
|
1107
1209
|
fileCount = 0;
|
|
1108
1210
|
filePaths = [];
|
|
1211
|
+
redactedValues = 0;
|
|
1212
|
+
redactedFiles = [];
|
|
1109
1213
|
};
|
|
1110
1214
|
for (const file of files) {
|
|
1111
1215
|
let content = "";
|
|
1112
1216
|
try {
|
|
1113
1217
|
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
1114
1218
|
}
|
|
1115
|
-
catch {
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
// The listing is the working tree's, so this is a race with a
|
|
1221
|
+
// concurrent delete rather than a listed-but-deleted file; skip it.
|
|
1222
|
+
if (error.code === "ENOENT")
|
|
1223
|
+
continue;
|
|
1116
1224
|
content = "[BINARY or UNREADABLE]";
|
|
1117
1225
|
}
|
|
1226
|
+
if (redact) {
|
|
1227
|
+
// Before the slice is built, so the count and the chars the estimate
|
|
1228
|
+
// sees are of what is actually sent (#252).
|
|
1229
|
+
const redaction = redactSecrets(content);
|
|
1230
|
+
if (redaction.count > 0) {
|
|
1231
|
+
content = redaction.text;
|
|
1232
|
+
redactedValues += redaction.count;
|
|
1233
|
+
redactedFiles.push(file.relPath);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1118
1236
|
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
1119
1237
|
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
1120
1238
|
flush();
|
|
@@ -1134,12 +1252,13 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1134
1252
|
flush();
|
|
1135
1253
|
return slices;
|
|
1136
1254
|
}
|
|
1137
|
-
export async function gatherSlices(targetDir, lens, info) {
|
|
1255
|
+
export async function gatherSlices(targetDir, lens, info, opts = {}) {
|
|
1256
|
+
const redact = opts.redact ?? true;
|
|
1138
1257
|
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
1139
1258
|
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
1140
1259
|
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
1141
1260
|
}
|
|
1142
|
-
const allFiles = await listRepoFiles(targetDir);
|
|
1261
|
+
const { files: allFiles } = await listRepoFiles(targetDir);
|
|
1143
1262
|
const files = collectLensFiles(allFiles, lens, info);
|
|
1144
1263
|
const totalChars = await sumFileSizes(targetDir, files);
|
|
1145
1264
|
const mode = resolveSliceMode(lens, files, totalChars);
|
|
@@ -1147,9 +1266,9 @@ export async function gatherSlices(targetDir, lens, info) {
|
|
|
1147
1266
|
// Whole-repo slice: one module named after the repo, so a small
|
|
1148
1267
|
// repo produces a single request instead of one per directory.
|
|
1149
1268
|
const single = files.map((f) => ({ ...f, moduleName: info.name }));
|
|
1150
|
-
return slurpFileList(targetDir, single, lens.maxChars);
|
|
1269
|
+
return slurpFileList(targetDir, single, lens.maxChars, redact);
|
|
1151
1270
|
}
|
|
1152
|
-
return slurpFileList(targetDir, files, lens.maxChars);
|
|
1271
|
+
return slurpFileList(targetDir, files, lens.maxChars, redact);
|
|
1153
1272
|
}
|
|
1154
1273
|
async function sumFileSizes(targetDir, files) {
|
|
1155
1274
|
let total = 0;
|
|
@@ -1199,7 +1318,12 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
|
|
|
1199
1318
|
* estimate covers slice content only, which is what the old signature did.
|
|
1200
1319
|
*/
|
|
1201
1320
|
export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
|
|
1202
|
-
|
|
1321
|
+
// With repo info, size each request from the user prompt that would be
|
|
1322
|
+
// sent, which is what the architecture lens is made of: it used to be
|
|
1323
|
+
// estimated at a flat 6,000 chars while the entry point, manifest, README
|
|
1324
|
+
// excerpt and file tree it carries ran to whatever they ran to (#249).
|
|
1325
|
+
// Without info, the slice content alone is what the old signature covered.
|
|
1326
|
+
const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
|
|
1203
1327
|
// The system prompt and the response schema ride on every request, so they
|
|
1204
1328
|
// are paid once per slice rather than once per lens.
|
|
1205
1329
|
const perRequestOverhead = info
|
|
@@ -1279,9 +1403,7 @@ export async function saveBroadsideState(broadsideDir, state) {
|
|
|
1279
1403
|
}
|
|
1280
1404
|
/** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
|
|
1281
1405
|
async function writeBroadsideStateFile(statePath, state) {
|
|
1282
|
-
|
|
1283
|
-
await writeFile(tempPath, `${JSON.stringify(state, null, "\t")}\n`, "utf8");
|
|
1284
|
-
await rename(tempPath, statePath);
|
|
1406
|
+
await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
1285
1407
|
}
|
|
1286
1408
|
/**
|
|
1287
1409
|
* Read-modify-write `state.json` under a lock.
|
|
@@ -1395,6 +1517,7 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1395
1517
|
includeSynthesis: flag("include_synthesis", true),
|
|
1396
1518
|
includeTriage: flag("include_triage", true),
|
|
1397
1519
|
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
1520
|
+
redactSecrets: flag("redact_secrets", true),
|
|
1398
1521
|
};
|
|
1399
1522
|
}
|
|
1400
1523
|
async function readCatalogCache(broadsideDir) {
|
|
@@ -1493,20 +1616,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1493
1616
|
if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
1494
1617
|
return { model, source: "cache", entry: cached };
|
|
1495
1618
|
}
|
|
1619
|
+
// What went wrong when the live lookup produced nothing, for the error
|
|
1620
|
+
// below: a 401 and a dead network used to read the same — "could not
|
|
1621
|
+
// resolve per-token pricing" — or, for the default model, nothing at all.
|
|
1496
1622
|
let live = null;
|
|
1623
|
+
let catalogFailure = null;
|
|
1497
1624
|
try {
|
|
1498
1625
|
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
1499
1626
|
method: "GET",
|
|
1500
1627
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1501
1628
|
signal: AbortSignal.timeout(30_000),
|
|
1502
1629
|
});
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1630
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
1631
|
+
throw new BroadsideAuthError(resp.status, await responseDetail(resp));
|
|
1632
|
+
}
|
|
1633
|
+
if (resp.ok === false) {
|
|
1634
|
+
catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
const data = (await resp.json());
|
|
1638
|
+
const hit = (data.data ?? []).find((m) => String(m.id) === model);
|
|
1639
|
+
if (hit)
|
|
1640
|
+
live = parseCatalogEntry(hit);
|
|
1641
|
+
else
|
|
1642
|
+
catalogFailure = `the model catalog has no entry for "${model}"`;
|
|
1643
|
+
}
|
|
1507
1644
|
}
|
|
1508
|
-
catch {
|
|
1645
|
+
catch (error) {
|
|
1646
|
+
if (error instanceof BroadsideAuthError)
|
|
1647
|
+
throw error;
|
|
1509
1648
|
live = null;
|
|
1649
|
+
catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
|
|
1510
1650
|
}
|
|
1511
1651
|
if (live) {
|
|
1512
1652
|
const updated = {
|
|
@@ -1523,10 +1663,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1523
1663
|
const builtIn = builtInCatalogEntry(model);
|
|
1524
1664
|
if (builtIn)
|
|
1525
1665
|
return { model, source: "built-in", entry: builtIn };
|
|
1526
|
-
throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
|
|
1666
|
+
throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
|
|
1527
1667
|
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
1528
1668
|
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
1529
1669
|
}
|
|
1670
|
+
/** A short, safe excerpt of an error response body for a message. */
|
|
1671
|
+
async function responseDetail(resp) {
|
|
1672
|
+
try {
|
|
1673
|
+
if (typeof resp.text === "function") {
|
|
1674
|
+
const text = (await resp.text()).trim();
|
|
1675
|
+
try {
|
|
1676
|
+
const parsed = JSON.parse(text);
|
|
1677
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1678
|
+
if (typeof message === "string" && message)
|
|
1679
|
+
return message.slice(0, 200);
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
// not JSON; fall through to the raw excerpt
|
|
1683
|
+
}
|
|
1684
|
+
return text.replace(/\s+/g, " ").slice(0, 200);
|
|
1685
|
+
}
|
|
1686
|
+
if (typeof resp.json === "function") {
|
|
1687
|
+
const parsed = (await resp.json());
|
|
1688
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1689
|
+
return typeof message === "string" ? message.slice(0, 200) : "";
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
catch {
|
|
1693
|
+
// an unreadable body adds nothing to the message
|
|
1694
|
+
}
|
|
1695
|
+
return "";
|
|
1696
|
+
}
|
|
1530
1697
|
export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1531
1698
|
const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
|
|
1532
1699
|
if (!entry)
|
|
@@ -1620,7 +1787,17 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1620
1787
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1621
1788
|
signal: AbortSignal.timeout(30_000),
|
|
1622
1789
|
});
|
|
1623
|
-
|
|
1790
|
+
let data;
|
|
1791
|
+
try {
|
|
1792
|
+
data = (await resp.json());
|
|
1793
|
+
}
|
|
1794
|
+
catch (error) {
|
|
1795
|
+
// A gateway error page is not JSON. It used to throw out of here and
|
|
1796
|
+
// be retried as if the network were down; keep the status instead.
|
|
1797
|
+
data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
|
|
1798
|
+
}
|
|
1799
|
+
if (!data || typeof data !== "object")
|
|
1800
|
+
data = { error: "empty response" };
|
|
1624
1801
|
// Surface the HTTP status so the poller can bail fast on auth expiry
|
|
1625
1802
|
// instead of retrying a dead key for the whole budget.
|
|
1626
1803
|
data.http_status = resp.status;
|
|
@@ -1638,14 +1815,26 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1638
1815
|
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1639
1816
|
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
1640
1817
|
const fetcher = opts.fetcher ?? fetch;
|
|
1818
|
+
// A poll that runs out of budget without one good response is not a slow
|
|
1819
|
+
// batch. The last thing that went wrong rides on the timeout so the report
|
|
1820
|
+
// can tell a dead network or a failing gateway from a batch still running.
|
|
1821
|
+
let lastError = null;
|
|
1822
|
+
let sawBatch = false;
|
|
1823
|
+
const timedOut = () => ({
|
|
1824
|
+
id: batchId,
|
|
1825
|
+
status: "timeout",
|
|
1826
|
+
...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
|
|
1827
|
+
...(lastError && sawBatch && { last_error: lastError }),
|
|
1828
|
+
});
|
|
1641
1829
|
for (;;) {
|
|
1642
1830
|
let batch;
|
|
1643
1831
|
try {
|
|
1644
1832
|
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
1645
1833
|
}
|
|
1646
|
-
catch {
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
|
|
1647
1836
|
if (Date.now() >= deadline)
|
|
1648
|
-
return
|
|
1837
|
+
return timedOut();
|
|
1649
1838
|
await sleep(intervalMs);
|
|
1650
1839
|
continue;
|
|
1651
1840
|
}
|
|
@@ -1653,13 +1842,23 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1653
1842
|
if (httpStatus === 401 || httpStatus === 403) {
|
|
1654
1843
|
return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
|
|
1655
1844
|
}
|
|
1845
|
+
if (httpStatus >= 400) {
|
|
1846
|
+
// A gateway or server error: retry within the budget, remembered.
|
|
1847
|
+
const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
|
|
1848
|
+
lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
|
|
1849
|
+
if (Date.now() >= deadline)
|
|
1850
|
+
return timedOut();
|
|
1851
|
+
await sleep(intervalMs);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
sawBatch = true;
|
|
1656
1855
|
const status = String(batch.status ?? "unknown");
|
|
1657
1856
|
const counts = (batch.request_counts ?? {});
|
|
1658
1857
|
opts.onStatus?.(status, counts);
|
|
1659
1858
|
if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
|
|
1660
1859
|
return batch;
|
|
1661
1860
|
if (Date.now() >= deadline)
|
|
1662
|
-
return
|
|
1861
|
+
return timedOut();
|
|
1663
1862
|
await sleep(intervalMs);
|
|
1664
1863
|
}
|
|
1665
1864
|
}
|
|
@@ -1686,7 +1885,6 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
|
1686
1885
|
}
|
|
1687
1886
|
// ---------- run orchestration ----------
|
|
1688
1887
|
export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
1689
|
-
const info = await collectRepoInfo(cwd);
|
|
1690
1888
|
const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
|
|
1691
1889
|
const broadsideDir = broadsideDirFor(cwd);
|
|
1692
1890
|
const model = opts.model ?? BROADSIDE_MODEL;
|
|
@@ -1695,6 +1893,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1695
1893
|
// structured-output support that not all batch models offer. Lenses may run
|
|
1696
1894
|
// on different models (config `lens_models`), so each one is pre-flighted.
|
|
1697
1895
|
const config = await loadBroadsideConfig(broadsideDir);
|
|
1896
|
+
const redact = config.redactSecrets;
|
|
1897
|
+
const info = await collectRepoInfo(cwd, { redact });
|
|
1898
|
+
// Before pricing, before the network, before any state write: a run on a
|
|
1899
|
+
// language the lenses cannot scan used to submit empty batches and pay for
|
|
1900
|
+
// them (#250).
|
|
1901
|
+
if (info.language === "unknown") {
|
|
1902
|
+
throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
|
|
1903
|
+
`and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
|
|
1904
|
+
}
|
|
1905
|
+
if (info.sourceFileCount === 0) {
|
|
1906
|
+
throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
|
|
1907
|
+
`the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
|
|
1908
|
+
}
|
|
1698
1909
|
const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
|
|
1699
1910
|
const resolved = new Map();
|
|
1700
1911
|
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
@@ -1760,9 +1971,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1760
1971
|
let estimatedOutputTokens = 0;
|
|
1761
1972
|
let estimatedTotalCost = 0;
|
|
1762
1973
|
const perLensEstimate = [];
|
|
1974
|
+
// What the redaction pass did across every lens's slices, for the run
|
|
1975
|
+
// record and the report: a value in a file shared by two lenses counts
|
|
1976
|
+
// once per lens it was sent in, files once each.
|
|
1977
|
+
let redactedValues = info.redactedValues;
|
|
1978
|
+
const redactedFiles = new Set();
|
|
1763
1979
|
for (const lensId of lensIds) {
|
|
1764
1980
|
const lens = getLens(lensId);
|
|
1765
|
-
let slices = await gatherSlices(cwd, lens, info);
|
|
1981
|
+
let slices = await gatherSlices(cwd, lens, info, { redact });
|
|
1982
|
+
for (const slice of slices) {
|
|
1983
|
+
redactedValues += slice.redactedValues ?? 0;
|
|
1984
|
+
for (const file of slice.redactedFiles ?? [])
|
|
1985
|
+
redactedFiles.add(file);
|
|
1986
|
+
}
|
|
1766
1987
|
if (changed) {
|
|
1767
1988
|
// Repo-info slices (empty files, e.g. architecture) always run;
|
|
1768
1989
|
// file-backed slices run only when one of their files changed.
|
|
@@ -1839,6 +2060,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1839
2060
|
sourceHead,
|
|
1840
2061
|
sourceDirty,
|
|
1841
2062
|
baseHead,
|
|
2063
|
+
snapshot: info.snapshot,
|
|
2064
|
+
language: info.language,
|
|
2065
|
+
redaction: {
|
|
2066
|
+
enabled: redact,
|
|
2067
|
+
values: redactedValues,
|
|
2068
|
+
files: redactedFiles.size,
|
|
2069
|
+
skippedFiles: info.secretFilesSkipped.length,
|
|
2070
|
+
},
|
|
1842
2071
|
};
|
|
1843
2072
|
state.runs.push(run);
|
|
1844
2073
|
await persistBroadsideRun(broadsideDir, run);
|
|
@@ -1917,6 +2146,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1917
2146
|
expirationDate: defaultEntry.expirationDate ?? null,
|
|
1918
2147
|
},
|
|
1919
2148
|
incremental: incrementalOutcome,
|
|
2149
|
+
repo: {
|
|
2150
|
+
language: info.language,
|
|
2151
|
+
sourceFiles: info.sourceFileCount,
|
|
2152
|
+
snapshot: info.snapshot,
|
|
2153
|
+
sourceHead,
|
|
2154
|
+
sourceDirty,
|
|
2155
|
+
},
|
|
2156
|
+
redaction: {
|
|
2157
|
+
enabled: redact,
|
|
2158
|
+
values: redactedValues,
|
|
2159
|
+
files: redactedFiles.size,
|
|
2160
|
+
skippedFiles: info.secretFilesSkipped,
|
|
2161
|
+
},
|
|
1920
2162
|
};
|
|
1921
2163
|
}
|
|
1922
2164
|
function extractContent(result) {
|
|
@@ -2189,7 +2431,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2189
2431
|
// indistinguishable in the output from one that was never requested.
|
|
2190
2432
|
if (batch.error)
|
|
2191
2433
|
entry.error = batch.error;
|
|
2192
|
-
|
|
2434
|
+
const error = describeBatchError(batch.error);
|
|
2435
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
2193
2436
|
}
|
|
2194
2437
|
await persistBroadsideRun(broadsideDir, run);
|
|
2195
2438
|
}
|
|
@@ -2536,6 +2779,18 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2536
2779
|
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2537
2780
|
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
|
|
2538
2781
|
}
|
|
2782
|
+
if (result.repo) {
|
|
2783
|
+
const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
|
|
2784
|
+
const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
|
|
2785
|
+
lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
|
|
2786
|
+
}
|
|
2787
|
+
if (result.redaction) {
|
|
2788
|
+
const line = result.redaction.enabled
|
|
2789
|
+
? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
|
|
2790
|
+
: "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
|
|
2791
|
+
if (line)
|
|
2792
|
+
lines.push(line);
|
|
2793
|
+
}
|
|
2539
2794
|
const incremental = result.incremental;
|
|
2540
2795
|
if (incremental?.requested) {
|
|
2541
2796
|
lines.push(incremental.applied
|
|
@@ -2588,6 +2843,25 @@ export function modelsText(entries, opts) {
|
|
|
2588
2843
|
lines.push("", "Set the batch model in .codecarto/broadside/config.yaml (model key). Higher coding index ≠ better scout: precision, context, and structured-output support matter most here.");
|
|
2589
2844
|
return lines.join("\n");
|
|
2590
2845
|
}
|
|
2846
|
+
/** One line of a batch's error field, whatever shape the provider gave it. */
|
|
2847
|
+
function describeBatchError(error) {
|
|
2848
|
+
if (error === undefined || error === null || error === "")
|
|
2849
|
+
return null;
|
|
2850
|
+
if (typeof error === "string")
|
|
2851
|
+
return error.slice(0, 300);
|
|
2852
|
+
if (typeof error === "object") {
|
|
2853
|
+
const message = error.message;
|
|
2854
|
+
if (typeof message === "string" && message)
|
|
2855
|
+
return message.slice(0, 300);
|
|
2856
|
+
try {
|
|
2857
|
+
return JSON.stringify(error).slice(0, 300);
|
|
2858
|
+
}
|
|
2859
|
+
catch {
|
|
2860
|
+
return String(error);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return String(error);
|
|
2864
|
+
}
|
|
2591
2865
|
export function collectResultText(result) {
|
|
2592
2866
|
const lines = [
|
|
2593
2867
|
`Broad-Side run ${result.runId}: ${result.status}`,
|
|
@@ -2601,7 +2875,10 @@ export function collectResultText(result) {
|
|
|
2601
2875
|
lines.push(` ${lensId}: ${outcome.status}` +
|
|
2602
2876
|
(outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
|
|
2603
2877
|
(outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
|
|
2604
|
-
truncation
|
|
2878
|
+
truncation +
|
|
2879
|
+
// The reason a lens did not complete, when the poll recorded one:
|
|
2880
|
+
// an auth failure or a dead network used to read as a slow batch.
|
|
2881
|
+
(outcome.error ? ` — ${outcome.error}` : ""));
|
|
2605
2882
|
}
|
|
2606
2883
|
if (result.retriedCount > 0) {
|
|
2607
2884
|
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
@@ -2641,6 +2918,12 @@ export function statusText(state) {
|
|
|
2641
2918
|
const lines = [];
|
|
2642
2919
|
for (const run of [...state.runs].reverse().slice(0, 3)) {
|
|
2643
2920
|
lines.push(`Run ${run.id} — ${run.status}`);
|
|
2921
|
+
// Recorded since #248; a run from an older version has neither field.
|
|
2922
|
+
if (run.language || run.snapshot) {
|
|
2923
|
+
const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
|
|
2924
|
+
const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
|
|
2925
|
+
lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
|
|
2926
|
+
}
|
|
2644
2927
|
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
2645
2928
|
const entry = run.batches[lensId];
|
|
2646
2929
|
if (!entry)
|