@nseng-ai/vibechk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/cli.ts +434 -0
- package/src/exec-util.ts +37 -0
- package/src/ids.ts +12 -0
- package/src/index.ts +11 -0
- package/src/models.ts +161 -0
- package/src/reports.ts +342 -0
- package/src/repository.ts +174 -0
- package/src/runners.ts +126 -0
- package/src/store.ts +249 -0
- package/src/workflow.ts +256 -0
package/src/reports.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type { RenderCapabilities } from "@nseng-ai/clinkr";
|
|
2
|
+
import { renderTextTable, type TextTableColumn } from "@nseng-ai/foundation/text-table";
|
|
3
|
+
|
|
4
|
+
import type { ArtifactOutputBounds, LoadedBundle } from "./models.ts";
|
|
5
|
+
import { encodeMetrics } from "./models.ts";
|
|
6
|
+
|
|
7
|
+
type RenderableBundle = LoadedBundle & {
|
|
8
|
+
readonly outputBounds?: {
|
|
9
|
+
readonly plan: ArtifactOutputBounds;
|
|
10
|
+
readonly transcript: ArtifactOutputBounds;
|
|
11
|
+
readonly diff: ArtifactOutputBounds;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type MetricRow = [string, keyof LoadedBundle["bundle"]["metrics"]];
|
|
16
|
+
|
|
17
|
+
const METRIC_ROWS: MetricRow[] = [
|
|
18
|
+
["Wall time seconds", "wallTimeSeconds"],
|
|
19
|
+
["Input tokens", "inputTokens"],
|
|
20
|
+
["Output tokens", "outputTokens"],
|
|
21
|
+
["Total tokens", "totalTokens"],
|
|
22
|
+
["Cost USD", "costUsd"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const RUNS_TABLE_COLUMNS = [
|
|
26
|
+
{ header: "RUN ID" },
|
|
27
|
+
{ header: "STARTED AT" },
|
|
28
|
+
{ header: "STATUS" },
|
|
29
|
+
{ header: "RUNNER" },
|
|
30
|
+
{ header: "MODEL" },
|
|
31
|
+
{ header: "BRANCH" },
|
|
32
|
+
{ header: "WORKDIR" },
|
|
33
|
+
] satisfies readonly TextTableColumn[];
|
|
34
|
+
|
|
35
|
+
export function renderRunReport(loaded: RenderableBundle): string {
|
|
36
|
+
const bundle = loaded.bundle;
|
|
37
|
+
const resultBranch = bundle.resultBranch ?? "null";
|
|
38
|
+
const diffPatch = diffBody(loaded.diffPatch);
|
|
39
|
+
const transcript = transcriptBody(loaded.transcript);
|
|
40
|
+
|
|
41
|
+
const lines = [
|
|
42
|
+
`# Vibechk Run \`${bundle.runId}\``,
|
|
43
|
+
"",
|
|
44
|
+
"## Summary",
|
|
45
|
+
"",
|
|
46
|
+
`- Status: ${bundle.status}`,
|
|
47
|
+
`- Runner: ${bundle.runner}`,
|
|
48
|
+
`- Model: ${formatValue(bundle.model)}`,
|
|
49
|
+
`- Workdir: ${bundle.workdir}`,
|
|
50
|
+
`- Started: ${bundle.startedAt.toISOString()}`,
|
|
51
|
+
`- Finished: ${bundle.finishedAt.toISOString()}`,
|
|
52
|
+
`- Starting branch: ${bundle.git.startingBranch}`,
|
|
53
|
+
`- Starting commit: ${bundle.git.startingCommit}`,
|
|
54
|
+
`- Result branch: ${resultBranch}`,
|
|
55
|
+
"",
|
|
56
|
+
"## Metrics",
|
|
57
|
+
"",
|
|
58
|
+
"| Metric | Value |",
|
|
59
|
+
"| --- | --- |",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const [label, fieldName] of METRIC_ROWS) {
|
|
63
|
+
lines.push(`| ${label} | ${formatValue(bundle.metrics[fieldName])} |`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push("", ...renderArtifactBounds(loaded), "");
|
|
67
|
+
|
|
68
|
+
lines.push(
|
|
69
|
+
"<details>",
|
|
70
|
+
"<summary>Plan</summary>",
|
|
71
|
+
"",
|
|
72
|
+
"```markdown",
|
|
73
|
+
loaded.planText,
|
|
74
|
+
"```",
|
|
75
|
+
"",
|
|
76
|
+
"</details>",
|
|
77
|
+
"",
|
|
78
|
+
"<details>",
|
|
79
|
+
"<summary>Transcript</summary>",
|
|
80
|
+
"",
|
|
81
|
+
"```text",
|
|
82
|
+
transcript,
|
|
83
|
+
"```",
|
|
84
|
+
"",
|
|
85
|
+
"</details>",
|
|
86
|
+
"",
|
|
87
|
+
"## Diff",
|
|
88
|
+
"",
|
|
89
|
+
"```diff",
|
|
90
|
+
diffPatch,
|
|
91
|
+
"```",
|
|
92
|
+
"",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function renderRunsTable(
|
|
99
|
+
loadedBundles: LoadedBundle[],
|
|
100
|
+
caps: RenderCapabilities = { canEmitAnsi: false },
|
|
101
|
+
): string {
|
|
102
|
+
return renderTextTable({
|
|
103
|
+
columns: RUNS_TABLE_COLUMNS,
|
|
104
|
+
rows: loadedBundles.map((loaded) => runsTableRow(loaded)),
|
|
105
|
+
canEmitAnsi: caps.canEmitAnsi,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function runListEntryToJson(loaded: LoadedBundle): Record<string, unknown> {
|
|
110
|
+
const bundle = loaded.bundle;
|
|
111
|
+
return {
|
|
112
|
+
run_id: bundle.runId,
|
|
113
|
+
started_at: bundle.startedAt.toISOString(),
|
|
114
|
+
finished_at: bundle.finishedAt.toISOString(),
|
|
115
|
+
status: bundle.status,
|
|
116
|
+
runner: bundle.runner,
|
|
117
|
+
runner_version: bundle.runnerVersion,
|
|
118
|
+
model: bundle.model,
|
|
119
|
+
workdir: bundle.workdir,
|
|
120
|
+
starting_branch: bundle.git.startingBranch,
|
|
121
|
+
starting_commit: bundle.git.startingCommit,
|
|
122
|
+
result_branch: bundle.resultBranch,
|
|
123
|
+
branch_created: bundle.branchCreated,
|
|
124
|
+
runner_exit_code: bundle.runnerExitCode,
|
|
125
|
+
metrics: encodeMetrics(bundle.metrics),
|
|
126
|
+
run_dir: loaded.runDir,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderComparisonReport(
|
|
131
|
+
baseline: RenderableBundle,
|
|
132
|
+
treatment: RenderableBundle,
|
|
133
|
+
): string {
|
|
134
|
+
const baselineBundle = baseline.bundle;
|
|
135
|
+
const treatmentBundle = treatment.bundle;
|
|
136
|
+
|
|
137
|
+
const lines = [
|
|
138
|
+
"# Vibechk Comparison",
|
|
139
|
+
"",
|
|
140
|
+
`Baseline: \`${baselineBundle.runId}\` `,
|
|
141
|
+
`Treatment: \`${treatmentBundle.runId}\``,
|
|
142
|
+
"",
|
|
143
|
+
"## Biggest Metric Deltas",
|
|
144
|
+
"",
|
|
145
|
+
deltaBullet(
|
|
146
|
+
"Wall time seconds",
|
|
147
|
+
baselineBundle.metrics.wallTimeSeconds,
|
|
148
|
+
treatmentBundle.metrics.wallTimeSeconds,
|
|
149
|
+
),
|
|
150
|
+
deltaBullet(
|
|
151
|
+
"Total tokens",
|
|
152
|
+
baselineBundle.metrics.totalTokens,
|
|
153
|
+
treatmentBundle.metrics.totalTokens,
|
|
154
|
+
),
|
|
155
|
+
deltaBullet(
|
|
156
|
+
"Input tokens",
|
|
157
|
+
baselineBundle.metrics.inputTokens,
|
|
158
|
+
treatmentBundle.metrics.inputTokens,
|
|
159
|
+
),
|
|
160
|
+
deltaBullet(
|
|
161
|
+
"Output tokens",
|
|
162
|
+
baselineBundle.metrics.outputTokens,
|
|
163
|
+
treatmentBundle.metrics.outputTokens,
|
|
164
|
+
),
|
|
165
|
+
deltaBullet("Cost USD", baselineBundle.metrics.costUsd, treatmentBundle.metrics.costUsd),
|
|
166
|
+
"",
|
|
167
|
+
"## Configuration",
|
|
168
|
+
"",
|
|
169
|
+
"| Field | Baseline | Treatment |",
|
|
170
|
+
"| --- | --- | --- |",
|
|
171
|
+
configRow("Runner", baselineBundle.runner, treatmentBundle.runner),
|
|
172
|
+
configRow("Runner version", baselineBundle.runnerVersion, treatmentBundle.runnerVersion),
|
|
173
|
+
configRow("Model", baselineBundle.model, treatmentBundle.model),
|
|
174
|
+
configRow(
|
|
175
|
+
"Starting branch",
|
|
176
|
+
baselineBundle.git.startingBranch,
|
|
177
|
+
treatmentBundle.git.startingBranch,
|
|
178
|
+
),
|
|
179
|
+
configRow(
|
|
180
|
+
"Starting commit",
|
|
181
|
+
baselineBundle.git.startingCommit,
|
|
182
|
+
treatmentBundle.git.startingCommit,
|
|
183
|
+
),
|
|
184
|
+
configRow("Result branch", baselineBundle.resultBranch, treatmentBundle.resultBranch),
|
|
185
|
+
"",
|
|
186
|
+
"## Metrics",
|
|
187
|
+
"",
|
|
188
|
+
"| Metric | Baseline | Treatment | Delta |",
|
|
189
|
+
"| --- | --- | --- | --- |",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const [label, fieldName] of METRIC_ROWS) {
|
|
193
|
+
const baselineValue = baselineBundle.metrics[fieldName];
|
|
194
|
+
const treatmentValue = treatmentBundle.metrics[fieldName];
|
|
195
|
+
lines.push(
|
|
196
|
+
`| ${label} | ${formatValue(baselineValue)} | ${formatValue(treatmentValue)} | ${formatDelta(baselineValue, treatmentValue)} |`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lines.push("", ...renderComparisonArtifactBounds(baseline, treatment), "");
|
|
201
|
+
lines.push(...renderPlanComparison(baseline.planText, treatment.planText));
|
|
202
|
+
lines.push(
|
|
203
|
+
"",
|
|
204
|
+
"## Baseline Diff",
|
|
205
|
+
"",
|
|
206
|
+
"```diff",
|
|
207
|
+
diffBody(baseline.diffPatch),
|
|
208
|
+
"```",
|
|
209
|
+
"",
|
|
210
|
+
"## Treatment Diff",
|
|
211
|
+
"",
|
|
212
|
+
"```diff",
|
|
213
|
+
diffBody(treatment.diffPatch),
|
|
214
|
+
"```",
|
|
215
|
+
"",
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function deltaBullet(label: string, baseline: number | null, treatment: number | null): string {
|
|
222
|
+
if (baseline === null || treatment === null) {
|
|
223
|
+
return `- ${label}: ${formatValue(baseline)} -> ${formatValue(treatment)} (n/a)`;
|
|
224
|
+
}
|
|
225
|
+
const delta = treatment - baseline;
|
|
226
|
+
const sign = delta >= 0 ? "+" : "";
|
|
227
|
+
return `- ${label}: ${baseline} -> ${treatment} (${sign}${delta})`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatDelta(baseline: number | null, treatment: number | null): string {
|
|
231
|
+
if (baseline === null || treatment === null) {
|
|
232
|
+
return "n/a";
|
|
233
|
+
}
|
|
234
|
+
const delta = treatment - baseline;
|
|
235
|
+
const sign = delta >= 0 ? "+" : "";
|
|
236
|
+
return `${sign}${delta}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function configRow(label: string, baseline: string | null, treatment: string | null): string {
|
|
240
|
+
return `| ${label} | ${formatValue(baseline)} | ${formatValue(treatment)} |`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function renderPlanComparison(baselinePlan: string, treatmentPlan: string): string[] {
|
|
244
|
+
if (baselinePlan === treatmentPlan) {
|
|
245
|
+
return [
|
|
246
|
+
"<details>",
|
|
247
|
+
"<summary>Plan</summary>",
|
|
248
|
+
"",
|
|
249
|
+
"```markdown",
|
|
250
|
+
baselinePlan,
|
|
251
|
+
"```",
|
|
252
|
+
"",
|
|
253
|
+
"</details>",
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
return [
|
|
257
|
+
"> Warning: baseline and treatment plans differ.",
|
|
258
|
+
"",
|
|
259
|
+
"<details>",
|
|
260
|
+
"<summary>Baseline Plan</summary>",
|
|
261
|
+
"",
|
|
262
|
+
"```markdown",
|
|
263
|
+
baselinePlan,
|
|
264
|
+
"```",
|
|
265
|
+
"",
|
|
266
|
+
"</details>",
|
|
267
|
+
"",
|
|
268
|
+
"<details>",
|
|
269
|
+
"<summary>Treatment Plan</summary>",
|
|
270
|
+
"",
|
|
271
|
+
"```markdown",
|
|
272
|
+
treatmentPlan,
|
|
273
|
+
"```",
|
|
274
|
+
"",
|
|
275
|
+
"</details>",
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderArtifactBounds(loaded: RenderableBundle): string[] {
|
|
280
|
+
const bounds = renderArtifactBoundsLines(loaded);
|
|
281
|
+
if (bounds.length === 0) return [];
|
|
282
|
+
return ["## Output Bounds", "", ...bounds];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderComparisonArtifactBounds(
|
|
286
|
+
baseline: RenderableBundle,
|
|
287
|
+
treatment: RenderableBundle,
|
|
288
|
+
): string[] {
|
|
289
|
+
const lines: string[] = [];
|
|
290
|
+
const baselineBounds = renderArtifactBoundsLines(baseline, "Baseline");
|
|
291
|
+
const treatmentBounds = renderArtifactBoundsLines(treatment, "Treatment");
|
|
292
|
+
if (baselineBounds.length === 0 && treatmentBounds.length === 0) return [];
|
|
293
|
+
lines.push("## Output Bounds", "");
|
|
294
|
+
lines.push(...baselineBounds, ...treatmentBounds);
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderArtifactBoundsLines(loaded: RenderableBundle, label?: string): string[] {
|
|
299
|
+
if (loaded.outputBounds === undefined) return [];
|
|
300
|
+
return Object.values(loaded.outputBounds)
|
|
301
|
+
.filter((bounds) => !bounds.isComplete)
|
|
302
|
+
.map((bounds) => formatArtifactBoundsBullet(bounds, label));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatArtifactBoundsBullet(
|
|
306
|
+
bounds: ArtifactOutputBounds,
|
|
307
|
+
label: string | undefined,
|
|
308
|
+
): string {
|
|
309
|
+
const labelPrefix = label === undefined ? "" : `${label} `;
|
|
310
|
+
return `- ${labelPrefix}${artifactLabel(bounds.artifact)} truncated: returned ${bounds.returnedBytes} of ${bounds.originalBytes} bytes (limit ${bounds.appliedByteLimit}). ${bounds.continuation ?? ""}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function artifactLabel(artifact: ArtifactOutputBounds["artifact"]): string {
|
|
314
|
+
if (artifact === "diff") return "diff";
|
|
315
|
+
if (artifact === "plan") return "plan";
|
|
316
|
+
return "transcript";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatValue(value: string | number | null): string {
|
|
320
|
+
return value === null ? "null" : String(value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function runsTableRow(loaded: LoadedBundle): string[] {
|
|
324
|
+
const bundle = loaded.bundle;
|
|
325
|
+
return [
|
|
326
|
+
bundle.runId,
|
|
327
|
+
bundle.startedAt.toISOString(),
|
|
328
|
+
bundle.status,
|
|
329
|
+
bundle.runner,
|
|
330
|
+
formatValue(bundle.model) === "null" ? "-" : formatValue(bundle.model),
|
|
331
|
+
bundle.resultBranch ?? "-",
|
|
332
|
+
bundle.workdir,
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function transcriptBody(transcript: string): string {
|
|
337
|
+
return transcript === "" ? "_No transcript captured._" : transcript;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function diffBody(diffPatch: string): string {
|
|
341
|
+
return diffPatch === "" ? "_No workdir changes._" : diffPatch;
|
|
342
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { NodeCommandExecApi } from "@nseng-ai/foundation/exec";
|
|
2
|
+
import {
|
|
3
|
+
type CommandExecApi,
|
|
4
|
+
type ExecResult,
|
|
5
|
+
formatCommand,
|
|
6
|
+
formatCommandFailure,
|
|
7
|
+
} from "@nseng-ai/foundation/exec";
|
|
8
|
+
import { RealGitGateway } from "@nseng-ai/capability-kit/git";
|
|
9
|
+
import type { GitGateway, GitOperationResult, GitResult } from "@nseng-ai/capability-kit/git";
|
|
10
|
+
|
|
11
|
+
import { isMissingExecutableError, runVibechkCommand } from "./exec-util.ts";
|
|
12
|
+
import type { GitProvenance } from "./models.ts";
|
|
13
|
+
import { VibechkError } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
export interface VibechkWorkdirGateway {
|
|
18
|
+
readProvenance(): Promise<GitProvenance>;
|
|
19
|
+
isClean(): Promise<boolean>;
|
|
20
|
+
diffPatch(): Promise<string>;
|
|
21
|
+
hasChanges(): Promise<boolean>;
|
|
22
|
+
createResultBranchAndCommit(branch: string, message: string): Promise<void>;
|
|
23
|
+
restoreBranch(branch: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RealVibechkWorkdirGatewayOptions {
|
|
27
|
+
workdir: string;
|
|
28
|
+
execApi?: CommandExecApi;
|
|
29
|
+
coreGit?: GitGateway;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RealVibechkWorkdirGateway implements VibechkWorkdirGateway {
|
|
33
|
+
private readonly workdir: string;
|
|
34
|
+
private readonly execApi: CommandExecApi;
|
|
35
|
+
private readonly coreGit: GitGateway;
|
|
36
|
+
|
|
37
|
+
constructor(options: RealVibechkWorkdirGatewayOptions) {
|
|
38
|
+
this.workdir = options.workdir;
|
|
39
|
+
this.execApi = options.execApi ?? new NodeCommandExecApi();
|
|
40
|
+
this.coreGit = options.coreGit ?? new RealGitGateway(this.execApi);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async readProvenance(): Promise<GitProvenance> {
|
|
44
|
+
return {
|
|
45
|
+
repoRoot: await this.repoRoot(),
|
|
46
|
+
startingBranch: await this.currentBranch(),
|
|
47
|
+
startingCommit: await this.currentCommit(),
|
|
48
|
+
remotes: await this.remotes(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async isClean(): Promise<boolean> {
|
|
53
|
+
return !(await this.hasChanges());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async diffPatch(): Promise<string> {
|
|
57
|
+
if (await this.hasChanges()) {
|
|
58
|
+
await this.runGit(
|
|
59
|
+
["add", "-N", "."],
|
|
60
|
+
`Could not prepare untracked files for diff in ${this.workdir}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return await this.runGit(
|
|
64
|
+
["diff", "--binary", "HEAD"],
|
|
65
|
+
`Could not capture git diff in ${this.workdir}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async hasChanges(): Promise<boolean> {
|
|
70
|
+
const result = await this.coreGit.hasUncommittedChangesUnder({
|
|
71
|
+
cwd: this.workdir,
|
|
72
|
+
relativePath: ".",
|
|
73
|
+
});
|
|
74
|
+
return this.unwrapGitResult(result, `Could not read git status in ${this.workdir}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async createResultBranchAndCommit(branch: string, message: string): Promise<void> {
|
|
78
|
+
const branchResult = await this.coreGit.createBranchAtHead({ cwd: this.workdir, branch });
|
|
79
|
+
this.unwrapGitOperationResult(branchResult, `Could not create result branch ${branch}`);
|
|
80
|
+
await this.runGit(["switch", branch], `Could not switch to result branch ${branch}`);
|
|
81
|
+
await this.runGit(["add", "-A"], `Could not stage vibechk changes on ${branch}`);
|
|
82
|
+
await this.runGit(["commit", "-m", message], `Could not commit vibechk changes on ${branch}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async restoreBranch(branch: string): Promise<void> {
|
|
86
|
+
await this.runGit(["switch", branch], `Could not switch back to branch ${branch}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async repoRoot(): Promise<string> {
|
|
90
|
+
const result = await this.coreGit.repoRoot({ cwd: this.workdir });
|
|
91
|
+
return this.unwrapGitResult(result, `Workdir ${this.workdir} is not a git repository.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async currentBranch(): Promise<string> {
|
|
95
|
+
const result = await this.coreGit.currentBranch({ cwd: this.workdir });
|
|
96
|
+
if (result.type === "branch") return result.branch;
|
|
97
|
+
if (result.type === "detached") {
|
|
98
|
+
throw new VibechkError(
|
|
99
|
+
`Could not determine current branch in ${this.workdir}\nDetached HEAD.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (isMissingExecutableError(result.error.message)) {
|
|
103
|
+
throw new VibechkError("git is not installed or not on PATH.");
|
|
104
|
+
}
|
|
105
|
+
throw new VibechkError(
|
|
106
|
+
`Could not determine current branch in ${this.workdir}\n${result.error.message}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async currentCommit(): Promise<string> {
|
|
111
|
+
const result = await this.coreGit.headCommit({ cwd: this.workdir });
|
|
112
|
+
return this.unwrapGitResult(result, `Could not determine current commit in ${this.workdir}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async remotes(): Promise<Record<string, string>> {
|
|
116
|
+
const output = await this.runGit(
|
|
117
|
+
["remote", "-v"],
|
|
118
|
+
`Could not list git remotes in ${this.workdir}`,
|
|
119
|
+
);
|
|
120
|
+
const remotes: Record<string, string> = {};
|
|
121
|
+
for (const line of output.split("\n")) {
|
|
122
|
+
const parts = line.split(/\s+/u);
|
|
123
|
+
if (
|
|
124
|
+
parts.length >= 2 &&
|
|
125
|
+
parts[0] !== undefined &&
|
|
126
|
+
parts[1] !== undefined &&
|
|
127
|
+
!(parts[0] in remotes)
|
|
128
|
+
) {
|
|
129
|
+
remotes[parts[0]] = parts[1];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return remotes;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private unwrapGitResult<T>(result: GitResult<T>, errorMessage: string): T {
|
|
136
|
+
if (result.ok) return result.value;
|
|
137
|
+
if (isMissingExecutableError(result.error.message)) {
|
|
138
|
+
throw new VibechkError("git is not installed or not on PATH.");
|
|
139
|
+
}
|
|
140
|
+
throw new VibechkError(`${errorMessage}\n${result.error.message}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private unwrapGitOperationResult(result: GitOperationResult, errorMessage: string): void {
|
|
144
|
+
if (result.ok) return;
|
|
145
|
+
if (isMissingExecutableError(result.error.message)) {
|
|
146
|
+
throw new VibechkError("git is not installed or not on PATH.");
|
|
147
|
+
}
|
|
148
|
+
throw new VibechkError(`${errorMessage}\n${result.error.message}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async runGit(args: readonly string[], errorMessage: string): Promise<string> {
|
|
152
|
+
const result = await this.runGitRaw(args);
|
|
153
|
+
if (result.code !== 0 || result.killed) {
|
|
154
|
+
throw new VibechkError(
|
|
155
|
+
formatCommandFailure(errorMessage, formatCommand("git", args), result),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return result.stdout.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async runGitRaw(args: readonly string[]): Promise<ExecResult> {
|
|
162
|
+
return await runVibechkCommand({
|
|
163
|
+
execApi: this.execApi,
|
|
164
|
+
command: "git",
|
|
165
|
+
args,
|
|
166
|
+
execOptions: {
|
|
167
|
+
cwd: this.workdir,
|
|
168
|
+
timeout: GIT_TIMEOUT_MS,
|
|
169
|
+
},
|
|
170
|
+
missingExecutableMessage: "git is not installed or not on PATH.",
|
|
171
|
+
startupFailurePrefix: "git command failed before completion",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/runners.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { NodeCommandExecApi } from "@nseng-ai/foundation/exec";
|
|
2
|
+
import type { CommandExecApi, ExecResult } from "@nseng-ai/foundation/exec";
|
|
3
|
+
|
|
4
|
+
import { runVibechkCommand } from "./exec-util.ts";
|
|
5
|
+
import type { Metrics } from "./models.ts";
|
|
6
|
+
import { VibechkError } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export interface RunnerRequest {
|
|
9
|
+
planText: string;
|
|
10
|
+
workdir: string;
|
|
11
|
+
model: string | null;
|
|
12
|
+
runId: string;
|
|
13
|
+
artifactsDir: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RunnerResult {
|
|
17
|
+
exitCode: number;
|
|
18
|
+
transcript: string;
|
|
19
|
+
metrics: Metrics;
|
|
20
|
+
artifacts: Record<string, unknown>;
|
|
21
|
+
runnerVersion: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Runner {
|
|
25
|
+
name: string;
|
|
26
|
+
run(
|
|
27
|
+
request: RunnerRequest,
|
|
28
|
+
transcriptSink: (text: string) => void,
|
|
29
|
+
stdoutSink: (text: string) => void,
|
|
30
|
+
): Promise<RunnerResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class RunnerRegistry {
|
|
34
|
+
private readonly runners: Map<string, Runner>;
|
|
35
|
+
|
|
36
|
+
constructor(runners: readonly Runner[]) {
|
|
37
|
+
this.runners = new Map(runners.map((runner) => [runner.name, runner]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get(name: string): Runner {
|
|
41
|
+
const runner = this.runners.get(name);
|
|
42
|
+
if (runner !== undefined) {
|
|
43
|
+
return runner;
|
|
44
|
+
}
|
|
45
|
+
const availableNames = Array.from(this.runners.keys()).sort().join(", ");
|
|
46
|
+
const available = availableNames === "" ? "none" : availableNames;
|
|
47
|
+
throw new VibechkError(`Unsupported runner '${name}'. Available runners: ${available}.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
names(): readonly string[] {
|
|
51
|
+
return Array.from(this.runners.keys()).sort();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ClaudeRunner implements Runner {
|
|
56
|
+
readonly name = "claude";
|
|
57
|
+
private readonly execApi: CommandExecApi;
|
|
58
|
+
|
|
59
|
+
constructor(execApi: CommandExecApi = new NodeCommandExecApi()) {
|
|
60
|
+
this.execApi = execApi;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async run(
|
|
64
|
+
request: RunnerRequest,
|
|
65
|
+
transcriptSink: (text: string) => void,
|
|
66
|
+
stdoutSink: (text: string) => void,
|
|
67
|
+
): Promise<RunnerResult> {
|
|
68
|
+
const command = this.buildCommand(request);
|
|
69
|
+
const started = performance.now();
|
|
70
|
+
|
|
71
|
+
const result = await this.executeCommand(command, request.workdir, transcriptSink, stdoutSink);
|
|
72
|
+
const wallTimeSeconds = Math.round((performance.now() - started) / 10) / 100;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
exitCode: result.code,
|
|
76
|
+
transcript: "",
|
|
77
|
+
metrics: {
|
|
78
|
+
wallTimeSeconds,
|
|
79
|
+
inputTokens: null,
|
|
80
|
+
outputTokens: null,
|
|
81
|
+
totalTokens: null,
|
|
82
|
+
costUsd: null,
|
|
83
|
+
},
|
|
84
|
+
artifacts: {},
|
|
85
|
+
runnerVersion: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private buildCommand(request: RunnerRequest): readonly [string, ...string[]] {
|
|
90
|
+
const command = ["claude", "--print", "--permission-mode", "acceptEdits"];
|
|
91
|
+
if (request.model !== null) {
|
|
92
|
+
command.push("--model", request.model);
|
|
93
|
+
}
|
|
94
|
+
command.push(request.planText);
|
|
95
|
+
return [command[0] ?? "claude", ...command.slice(1)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async executeCommand(
|
|
99
|
+
command: readonly [string, ...string[]],
|
|
100
|
+
workdir: string,
|
|
101
|
+
transcriptSink: (text: string) => void,
|
|
102
|
+
stdoutSink: (text: string) => void,
|
|
103
|
+
): Promise<ExecResult> {
|
|
104
|
+
const streamText = (text: string): void => {
|
|
105
|
+
stdoutSink(text);
|
|
106
|
+
transcriptSink(text);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return await runVibechkCommand({
|
|
110
|
+
execApi: this.execApi,
|
|
111
|
+
command: command[0],
|
|
112
|
+
args: command.slice(1),
|
|
113
|
+
execOptions: {
|
|
114
|
+
cwd: workdir,
|
|
115
|
+
onStdout: streamText,
|
|
116
|
+
onStderr: streamText,
|
|
117
|
+
},
|
|
118
|
+
missingExecutableMessage: "Runner 'claude' is not installed or not on PATH.",
|
|
119
|
+
startupFailurePrefix: "Runner 'claude' failed to start",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildProductionRunnerRegistry(): RunnerRegistry {
|
|
125
|
+
return new RunnerRegistry([new ClaudeRunner()]);
|
|
126
|
+
}
|