@forwardimpact/libharness 0.1.22 → 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/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +41 -11
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +522 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +583 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +298 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +165 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +111 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
6
|
+
|
|
7
|
+
const API = "https://api.github.com";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GitHub API client for trace-related operations: listing workflow runs
|
|
11
|
+
* and downloading trace artifacts.
|
|
12
|
+
*/
|
|
13
|
+
export class TraceGitHub {
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @param {string} deps.token - GitHub token
|
|
17
|
+
* @param {string} deps.owner - Repository owner
|
|
18
|
+
* @param {string} deps.repo - Repository name
|
|
19
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
|
|
20
|
+
* Ambient collaborators; uses `fs`, `subprocess`, `clock`.
|
|
21
|
+
*/
|
|
22
|
+
constructor({ token, owner, repo, runtime }) {
|
|
23
|
+
if (!runtime) throw new Error("runtime is required");
|
|
24
|
+
this.token = token;
|
|
25
|
+
this.owner = owner;
|
|
26
|
+
this.repo = repo;
|
|
27
|
+
this.runtime = runtime;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* List recent workflow runs, optionally filtered by name pattern and by the
|
|
32
|
+
* participant whose trace lane a run carries.
|
|
33
|
+
*
|
|
34
|
+
* Without `participant`, behaviour is unchanged: the workflow-name pattern is
|
|
35
|
+
* the only filter. With `participant`, each name-matched run is resolved
|
|
36
|
+
* against its trace lane (see {@link runMatchesParticipant}) and annotated
|
|
37
|
+
* with a `match` field:
|
|
38
|
+
* - `"confirmed"` — the participant's lane is present in the run's
|
|
39
|
+
* artifacts (matrix artifact name, or a member filename in the shared
|
|
40
|
+
* dispatch artifact).
|
|
41
|
+
* - `"unconfirmed-pending-artifacts"` — the run's workflow mints trace
|
|
42
|
+
* artifacts but none exist yet (still running, or completed-but-not-yet
|
|
43
|
+
* uploaded); reported as a candidate, never silently dropped.
|
|
44
|
+
* Runs that have artifacts but no matching lane are omitted. Participant
|
|
45
|
+
* identity is read from artifact/file *names* only, never from trace content.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} [opts]
|
|
48
|
+
* @param {string} [opts.pattern] - Case-insensitive regex to match workflow name (default: "kata|agent" — covers `Kata: Shift`, `Kata: Dispatch`, and any `agent`-named workflow)
|
|
49
|
+
* @param {number} [opts.limit=50] - Max runs to return from GitHub API
|
|
50
|
+
* @param {string} [opts.lookback="7d"] - How far back to search (e.g. "7d", "24h", "2w")
|
|
51
|
+
* @param {string} [opts.participant] - Participant name; when set, filter/annotate runs by trace lane
|
|
52
|
+
* @returns {Promise<object[]>} Array of {workflow, runId, status, conclusion, createdAt, branch, url[, match]}
|
|
53
|
+
*/
|
|
54
|
+
async listRuns(opts = {}) {
|
|
55
|
+
const { pattern = "kata|agent", limit = 50, lookback = "7d" } = opts;
|
|
56
|
+
const cutoff = parseLookback(lookback, this.runtime.clock.now());
|
|
57
|
+
|
|
58
|
+
const params = new URLSearchParams({
|
|
59
|
+
per_page: String(Math.min(limit, 100)),
|
|
60
|
+
});
|
|
61
|
+
if (cutoff) {
|
|
62
|
+
params.set("created", `>=${cutoff}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs?${params}`;
|
|
66
|
+
const data = await this.#get(url);
|
|
67
|
+
const runs = data.workflow_runs ?? [];
|
|
68
|
+
|
|
69
|
+
const re = new RegExp(pattern, "i");
|
|
70
|
+
const matched = runs
|
|
71
|
+
.filter((r) => re.test(r.name))
|
|
72
|
+
.map((r) => ({
|
|
73
|
+
workflow: r.name,
|
|
74
|
+
runId: r.id,
|
|
75
|
+
status: r.status,
|
|
76
|
+
conclusion: r.conclusion,
|
|
77
|
+
createdAt: r.created_at,
|
|
78
|
+
branch: r.head_branch,
|
|
79
|
+
url: r.html_url,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
if (!opts.participant) return matched;
|
|
83
|
+
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const run of matched) {
|
|
86
|
+
const verdict = await this.runMatchesParticipant(
|
|
87
|
+
run.runId,
|
|
88
|
+
opts.participant,
|
|
89
|
+
);
|
|
90
|
+
if (verdict === "omit") continue;
|
|
91
|
+
out.push({ ...run, match: verdict });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decide whether a run carries a participant's trace lane.
|
|
98
|
+
*
|
|
99
|
+
* Matrix hosts name the participant in an artifact name
|
|
100
|
+
* (`trace--<participant>`); dispatch hosts name it in a member filename
|
|
101
|
+
* (`trace--<case>--<participant>.<role>.ndjson`) inside one shared `trace--*`
|
|
102
|
+
* artifact. The GitHub artifacts API exposes only artifact-level metadata, so
|
|
103
|
+
* a matrix lane confirms from the inventory alone, while a dispatch lane
|
|
104
|
+
* requires downloading the shared artifact and listing its extracted member
|
|
105
|
+
* filenames — names only, never trace content.
|
|
106
|
+
*
|
|
107
|
+
* A run whose trace artifacts are absent (still running, or
|
|
108
|
+
* completed-but-not-yet-uploaded) is a candidate, not a drop.
|
|
109
|
+
*
|
|
110
|
+
* @param {number|string} runId
|
|
111
|
+
* @param {string} participant
|
|
112
|
+
* @returns {Promise<"confirmed"|"unconfirmed-pending-artifacts"|"omit">}
|
|
113
|
+
*/
|
|
114
|
+
async runMatchesParticipant(runId, participant) {
|
|
115
|
+
const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
|
|
116
|
+
const data = await this.#get(url);
|
|
117
|
+
const artifacts = data.artifacts ?? [];
|
|
118
|
+
const traceArtifacts = artifacts.filter((a) =>
|
|
119
|
+
a.name.startsWith("trace--"),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// No trace artifacts yet: a candidate the matcher must report, not drop —
|
|
123
|
+
// the lane may upload when the host completes.
|
|
124
|
+
if (traceArtifacts.length === 0) return "unconfirmed-pending-artifacts";
|
|
125
|
+
|
|
126
|
+
// Matrix host: the participant is an artifact name. No download.
|
|
127
|
+
if (
|
|
128
|
+
participantInNames(
|
|
129
|
+
traceArtifacts.map((a) => a.name),
|
|
130
|
+
participant,
|
|
131
|
+
)
|
|
132
|
+
) {
|
|
133
|
+
return "confirmed";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Dispatch host: one shared artifact whose members name the participant.
|
|
137
|
+
// Download and list member filenames (names only).
|
|
138
|
+
for (const artifact of traceArtifacts) {
|
|
139
|
+
const { files } = await this.downloadTrace(runId, {
|
|
140
|
+
name: artifact.name,
|
|
141
|
+
});
|
|
142
|
+
if (participantInNames(files, participant)) return "confirmed";
|
|
143
|
+
}
|
|
144
|
+
return "omit";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a participant's lane trace path for a known run in one keyed
|
|
149
|
+
* lookup — no run enumeration, no trace-content inspection.
|
|
150
|
+
*
|
|
151
|
+
* Matrix host: the artifact name carries the participant (no download).
|
|
152
|
+
* Dispatch host: download the shared `trace--*` artifact and return the
|
|
153
|
+
* extracted member file whose name carries the participant.
|
|
154
|
+
*
|
|
155
|
+
* @param {number|string} runId
|
|
156
|
+
* @param {string} participant
|
|
157
|
+
* @param {object} [opts]
|
|
158
|
+
* @param {string} [opts.dir] - Output directory for a downloaded dispatch artifact
|
|
159
|
+
* @returns {Promise<{runId: (number|string), participant: string, host: "matrix"|"dispatch", artifact: string, path: string}>}
|
|
160
|
+
* @throws {Error} when the run has no trace artifacts, or none carries the participant's lane.
|
|
161
|
+
*/
|
|
162
|
+
async findByKey(runId, participant, opts = {}) {
|
|
163
|
+
const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
|
|
164
|
+
const data = await this.#get(url);
|
|
165
|
+
const artifacts = data.artifacts ?? [];
|
|
166
|
+
const traceArtifacts = artifacts.filter((a) =>
|
|
167
|
+
a.name.startsWith("trace--"),
|
|
168
|
+
);
|
|
169
|
+
if (traceArtifacts.length === 0) {
|
|
170
|
+
throw new Error(`No trace artifacts for run ${runId}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Matrix host: the artifact name carries the participant. No download.
|
|
174
|
+
const matrix = traceArtifacts.find((a) =>
|
|
175
|
+
participantInNames([a.name], participant),
|
|
176
|
+
);
|
|
177
|
+
if (matrix) {
|
|
178
|
+
return {
|
|
179
|
+
runId,
|
|
180
|
+
participant,
|
|
181
|
+
host: "matrix",
|
|
182
|
+
artifact: matrix.name,
|
|
183
|
+
path: matrix.name,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Dispatch host: download the shared artifact and match a member filename.
|
|
188
|
+
for (const artifact of traceArtifacts) {
|
|
189
|
+
const { dir, files } = await this.downloadTrace(runId, {
|
|
190
|
+
name: artifact.name,
|
|
191
|
+
dir: opts.dir,
|
|
192
|
+
});
|
|
193
|
+
const member = files.find((f) => participantInNames([f], participant));
|
|
194
|
+
if (member) {
|
|
195
|
+
return {
|
|
196
|
+
runId,
|
|
197
|
+
participant,
|
|
198
|
+
host: "dispatch",
|
|
199
|
+
artifact: artifact.name,
|
|
200
|
+
path: path.join(dir, member),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error(
|
|
206
|
+
`No trace lane for participant "${participant}" in run ${runId}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Download a trace artifact from a workflow run and extract it.
|
|
212
|
+
*
|
|
213
|
+
* When `opts.name` is set, looks up that exact artifact. Otherwise picks
|
|
214
|
+
* the single `trace--*` artifact if exactly one exists, or throws with a
|
|
215
|
+
* disambiguation list when matrix workflows emit multiple per-participant
|
|
216
|
+
* artifacts (see {@link pickTraceArtifact}).
|
|
217
|
+
*
|
|
218
|
+
* @param {number|string} runId
|
|
219
|
+
* @param {object} [opts]
|
|
220
|
+
* @param {string} [opts.dir] - Output directory (default: /tmp/trace-<runId>)
|
|
221
|
+
* @param {string} [opts.name] - Specific artifact name to download
|
|
222
|
+
* @returns {Promise<{dir: string, artifact: string, files: string[]}>}
|
|
223
|
+
*/
|
|
224
|
+
async downloadTrace(runId, opts = {}) {
|
|
225
|
+
const fs = this.runtime.fs;
|
|
226
|
+
const dir = opts.dir ?? `/tmp/trace-${runId}`;
|
|
227
|
+
await fs.mkdir(dir, { recursive: true });
|
|
228
|
+
|
|
229
|
+
// List artifacts for this run.
|
|
230
|
+
const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
|
|
231
|
+
const data = await this.#get(url);
|
|
232
|
+
const artifacts = data.artifacts ?? [];
|
|
233
|
+
const artifact = pickTraceArtifact(artifacts, opts.name, runId);
|
|
234
|
+
|
|
235
|
+
// Download the zip.
|
|
236
|
+
const zipPath = path.join(dir, `${artifact.name}.zip`);
|
|
237
|
+
const downloadUrl = `${API}/repos/${this.owner}/${this.repo}/actions/artifacts/${artifact.id}/zip`;
|
|
238
|
+
const response = await fetch(downloadUrl, {
|
|
239
|
+
headers: this.#headers(),
|
|
240
|
+
redirect: "follow",
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Failed to download artifact: ${response.status} ${response.statusText}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Stream to disk then extract.
|
|
249
|
+
await pipeline(
|
|
250
|
+
Readable.fromWeb(response.body),
|
|
251
|
+
fs.createWriteStream(zipPath),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const unzip = await this.runtime.subprocess.run("unzip", [
|
|
255
|
+
"-o",
|
|
256
|
+
"-q",
|
|
257
|
+
zipPath,
|
|
258
|
+
"-d",
|
|
259
|
+
dir,
|
|
260
|
+
]);
|
|
261
|
+
if (unzip.exitCode !== 0) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`unzip failed (${unzip.exitCode}): ${unzip.stderr || unzip.stdout}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// List extracted files.
|
|
268
|
+
const entries = await fs.readdir(dir);
|
|
269
|
+
const files = entries.filter((f) => !f.endsWith(".zip"));
|
|
270
|
+
|
|
271
|
+
return { dir, artifact: artifact.name, files };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {string} url
|
|
276
|
+
* @returns {Promise<object>}
|
|
277
|
+
*/
|
|
278
|
+
async #get(url) {
|
|
279
|
+
const response = await fetch(url, { headers: this.#headers() });
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
throw new Error(`GitHub API: ${response.status} ${response.statusText}`);
|
|
282
|
+
}
|
|
283
|
+
return response.json();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @returns {Record<string, string>} */
|
|
287
|
+
#headers() {
|
|
288
|
+
return {
|
|
289
|
+
Authorization: `Bearer ${this.token}`,
|
|
290
|
+
Accept: "application/vnd.github+json",
|
|
291
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Test whether a participant's trace lane is present in a list of names.
|
|
298
|
+
*
|
|
299
|
+
* Matches the two trace-naming shapes by *name* only (never by content):
|
|
300
|
+
* - matrix artifact name: `trace--<participant>`
|
|
301
|
+
* - dispatch member filename: `trace--<case>--<participant>.<role>.ndjson`
|
|
302
|
+
*
|
|
303
|
+
* The participant segment is delimited by `--` and ends at the next `--`, `.`,
|
|
304
|
+
* or end-of-string, so a substring like `release` does not match
|
|
305
|
+
* `release-engineer` and vice versa.
|
|
306
|
+
*
|
|
307
|
+
* @param {string[]} names - Artifact names or extracted member filenames.
|
|
308
|
+
* @param {string} participant - Participant name to look for.
|
|
309
|
+
* @returns {boolean}
|
|
310
|
+
*/
|
|
311
|
+
export function participantInNames(names, participant) {
|
|
312
|
+
return names.some((name) => {
|
|
313
|
+
if (!name.startsWith("trace--")) return false;
|
|
314
|
+
const rest = name.slice("trace--".length);
|
|
315
|
+
// Matrix: `<participant>` is the whole remainder (artifact name).
|
|
316
|
+
if (rest === participant) return true;
|
|
317
|
+
// Dispatch: `<case>--<participant>.<role>.ndjson`.
|
|
318
|
+
const sep = rest.indexOf("--");
|
|
319
|
+
if (sep === -1) return false;
|
|
320
|
+
const afterCase = rest.slice(sep + 2);
|
|
321
|
+
const participantSegment = afterCase.split(".")[0];
|
|
322
|
+
return participantSegment === participant;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Pick the trace artifact to download from a workflow run's artifact list.
|
|
328
|
+
*
|
|
329
|
+
* When `name` is given, returns the exact match or throws with the available
|
|
330
|
+
* names. When `name` is omitted, returns the only `trace--*` artifact if
|
|
331
|
+
* there is exactly one; if there are multiple (matrix workflows like
|
|
332
|
+
* `kata-shift.yml` emit one `trace--<participant>` per cell), throws and
|
|
333
|
+
* lists them so the caller can pass `--name` to disambiguate.
|
|
334
|
+
*
|
|
335
|
+
* @param {Array<{name: string}>} artifacts - Artifact list from the GitHub API.
|
|
336
|
+
* @param {string} [name] - Exact artifact name to match.
|
|
337
|
+
* @param {number|string} [runId] - Run id for error messages.
|
|
338
|
+
* @returns {{name: string}} The selected artifact.
|
|
339
|
+
*/
|
|
340
|
+
export function pickTraceArtifact(artifacts, name, runId) {
|
|
341
|
+
const runRef = runId == null ? "" : ` for run ${runId}`;
|
|
342
|
+
if (name) {
|
|
343
|
+
const found = artifacts.find((a) => a.name === name);
|
|
344
|
+
if (found) return found;
|
|
345
|
+
const available = artifacts.map((a) => a.name).join(", ");
|
|
346
|
+
throw new Error(
|
|
347
|
+
`No artifact named "${name}"${runRef}. Available: ${available || "none"}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const traceArtifacts = artifacts.filter((a) => a.name.startsWith("trace--"));
|
|
352
|
+
if (traceArtifacts.length === 1) return traceArtifacts[0];
|
|
353
|
+
if (traceArtifacts.length === 0) {
|
|
354
|
+
const available = artifacts.map((a) => a.name).join(", ");
|
|
355
|
+
throw new Error(
|
|
356
|
+
`No trace artifact found${runRef}. Available: ${available || "none"}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const names = traceArtifacts.map((a) => a.name).join(", ");
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Multiple trace artifacts found${runRef}: ${names}. Pass --name to choose one.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Parse a lookback duration string into an ISO date string.
|
|
367
|
+
* Supports: Nd (days), Nh (hours), Nw (weeks).
|
|
368
|
+
* @param {string} lookback
|
|
369
|
+
* @param {number} nowMs - Current time in ms (`runtime.clock.now()`).
|
|
370
|
+
* @returns {string|null} ISO date string or null if unparseable
|
|
371
|
+
*/
|
|
372
|
+
function parseLookback(lookback, nowMs) {
|
|
373
|
+
const match = lookback.match(/^(\d+)([dhw])$/);
|
|
374
|
+
if (!match) return null;
|
|
375
|
+
const [, val, unit] = match;
|
|
376
|
+
const ms = { d: 86400000, h: 3600000, w: 604800000 }[unit];
|
|
377
|
+
return isoTimestamp(nowMs - parseInt(val, 10) * ms);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse a GitHub repository URL or "owner/repo" string.
|
|
382
|
+
* @param {string} remote - Git remote URL or owner/repo string
|
|
383
|
+
* @returns {{owner: string, repo: string}}
|
|
384
|
+
*/
|
|
385
|
+
export function parseGitRemote(remote) {
|
|
386
|
+
// SSH: git@github.com:owner/repo.git
|
|
387
|
+
const ssh = remote.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
388
|
+
if (ssh) return { owner: ssh[1], repo: ssh[2] };
|
|
389
|
+
|
|
390
|
+
// HTTPS: https://github.com/owner/repo
|
|
391
|
+
const https = remote.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
392
|
+
if (https) return { owner: https[1], repo: https[2] };
|
|
393
|
+
|
|
394
|
+
// Plain owner/repo format (no github.com prefix).
|
|
395
|
+
const simple = remote.match(/^([^/:@]+)\/([^/]+)$/);
|
|
396
|
+
if (simple) return { owner: simple[1], repo: simple[2] };
|
|
397
|
+
|
|
398
|
+
// Generic URL fallback: any remote whose path ends in /owner/repo(.git)?
|
|
399
|
+
// Covers GitHub Enterprise, proxied git URLs, and mirrors.
|
|
400
|
+
const generic = remote.match(/[/:]([^/:@?#]+)\/([^/:@?#]+?)(?:\.git)?\/?$/);
|
|
401
|
+
if (generic) return { owner: generic[1], repo: generic[2] };
|
|
402
|
+
|
|
403
|
+
throw new Error(`Cannot parse GitHub remote: ${remote}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Detect the current GitHub repository slug as `{owner, repo}`.
|
|
408
|
+
*
|
|
409
|
+
* Resolution order:
|
|
410
|
+
* 1. `GITHUB_REPOSITORY` env var (set automatically by GitHub Actions).
|
|
411
|
+
* 2. `git remote get-url origin` in the current working directory.
|
|
412
|
+
*
|
|
413
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
414
|
+
* @returns {Promise<{owner: string, repo: string}>}
|
|
415
|
+
* @throws {Error} with a clear message if neither source yields a parseable slug.
|
|
416
|
+
*/
|
|
417
|
+
export async function detectRepoSlug(runtime) {
|
|
418
|
+
const env = runtime.proc.env.GITHUB_REPOSITORY;
|
|
419
|
+
if (env && env.trim()) {
|
|
420
|
+
return parseGitRemote(env.trim());
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const result = await runtime.subprocess.run("git", [
|
|
424
|
+
"remote",
|
|
425
|
+
"get-url",
|
|
426
|
+
"origin",
|
|
427
|
+
]);
|
|
428
|
+
const remote = result.exitCode === 0 ? result.stdout.trim() : "";
|
|
429
|
+
if (result.exitCode !== 0) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
"Cannot detect repository: set --repo <owner/repo>, export GITHUB_REPOSITORY, or run inside a git checkout with an 'origin' remote.",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!remote) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
"Cannot detect repository: 'git remote get-url origin' returned an empty value. Pass --repo <owner/repo> or set GITHUB_REPOSITORY.",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return parseGitRemote(remote);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Create a TraceGitHub instance. The caller is responsible for resolving
|
|
446
|
+
* the GitHub token — typically via `Config.ghToken()` — so credential
|
|
447
|
+
* loading stays at the CLI entry point.
|
|
448
|
+
*
|
|
449
|
+
* Breaking change from the prior signature: `token` is now a required
|
|
450
|
+
* caller input. Construct a `Config` via `@forwardimpact/libconfig` and
|
|
451
|
+
* pass `config.ghToken()`.
|
|
452
|
+
*
|
|
453
|
+
* @param {object} opts
|
|
454
|
+
* @param {string} opts.token - GitHub token (e.g. from `Config.ghToken()`)
|
|
455
|
+
* @param {string} [opts.repo] - "owner/repo" override (default: detect from git remote)
|
|
456
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators.
|
|
457
|
+
* @returns {Promise<TraceGitHub>}
|
|
458
|
+
*/
|
|
459
|
+
export async function createTraceGitHub(opts = {}) {
|
|
460
|
+
const { token, repo: repoOverride, runtime } = opts;
|
|
461
|
+
if (!runtime) throw new Error("createTraceGitHub: runtime is required");
|
|
462
|
+
if (!token) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
"createTraceGitHub: token is required (pass Config.ghToken())",
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const { owner, repo } = repoOverride
|
|
469
|
+
? parseGitRemote(repoOverride)
|
|
470
|
+
: await detectRepoSlug(runtime);
|
|
471
|
+
|
|
472
|
+
return new TraceGitHub({ token, owner, repo, runtime });
|
|
473
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-file orchestrator for cross-trace `fit-trace` verbs.
|
|
3
|
+
*
|
|
4
|
+
* Two functions centralise the load-tag-concat (`runOver`) and
|
|
5
|
+
* aggregate-and-sort (`aggregate`) policies so every cross-trace verb shares
|
|
6
|
+
* one source-attribution rule. `compareTwo` derives per-side identity from
|
|
7
|
+
* each input's basename and threads it into `TraceQuery.compare()`.
|
|
8
|
+
*
|
|
9
|
+
* `load` is injected (the exported `loadTrace` from `commands/trace.js`) so
|
|
10
|
+
* this module stays IO-policy-free and unit-testable with a stub.
|
|
11
|
+
*/
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load each file → `TraceQuery`, run `query(tq)`, tag each emitted record with
|
|
16
|
+
* `source: <basename>` only when more than one file is supplied. Records are
|
|
17
|
+
* concatenated in file-then-record order.
|
|
18
|
+
* @param {string[]} files
|
|
19
|
+
* @param {(tq: object) => object[]} query
|
|
20
|
+
* @param {(file: string) => object} load
|
|
21
|
+
* @returns {object[]}
|
|
22
|
+
*/
|
|
23
|
+
export function runOver(files, query, load) {
|
|
24
|
+
const multi = files.length > 1;
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const source = basename(file);
|
|
28
|
+
const records = query(load(file));
|
|
29
|
+
for (const record of records) {
|
|
30
|
+
out.push(multi ? { ...record, source } : record);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge per-file record arrays by `key(record)`, summing each record's
|
|
38
|
+
* existing `count` field (not occurrence count), and frequency-sort by
|
|
39
|
+
* `count desc`. Merged records carry `sources: string[]` only when more than
|
|
40
|
+
* one file is supplied.
|
|
41
|
+
* @param {string[]} files
|
|
42
|
+
* @param {(tq: object) => Array<{count: number}>} query
|
|
43
|
+
* @param {(record: object) => string} key
|
|
44
|
+
* @param {(file: string) => object} load
|
|
45
|
+
* @returns {object[]}
|
|
46
|
+
*/
|
|
47
|
+
export function aggregate(files, query, key, load) {
|
|
48
|
+
const multi = files.length > 1;
|
|
49
|
+
const merged = new Map();
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const source = basename(file);
|
|
52
|
+
for (const record of query(load(file))) {
|
|
53
|
+
const k = key(record);
|
|
54
|
+
if (!merged.has(k)) {
|
|
55
|
+
merged.set(k, { record: { ...record }, sources: new Set() });
|
|
56
|
+
} else {
|
|
57
|
+
merged.get(k).record.count += record.count;
|
|
58
|
+
}
|
|
59
|
+
merged.get(k).sources.add(source);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...merged.values()]
|
|
63
|
+
.map(({ record, sources }) =>
|
|
64
|
+
multi ? { ...record, sources: [...sources].sort() } : record,
|
|
65
|
+
)
|
|
66
|
+
.sort((a, b) => b.count - a.count);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load two files, derive each side's `{caseName, participant}` from its
|
|
71
|
+
* basename via the `split` convention, and thread them into
|
|
72
|
+
* `a.compare(b, {aIdentity, bIdentity})`.
|
|
73
|
+
* @param {string} a
|
|
74
|
+
* @param {string} b
|
|
75
|
+
* @param {(file: string) => object} load
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
export function compareTwo(a, b, load) {
|
|
79
|
+
const qa = load(a);
|
|
80
|
+
const qb = load(b);
|
|
81
|
+
return qa.compare(qb, {
|
|
82
|
+
aIdentity: parseIdentity(a),
|
|
83
|
+
bIdentity: parseIdentity(b),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
|
|
89
|
+
* participant}`. On no match, `caseName` is the basename minus its final
|
|
90
|
+
* `.ndjson` extension only and `participant` is null.
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @returns {{caseName: string, participant: string|null}}
|
|
93
|
+
*/
|
|
94
|
+
export function parseIdentity(file) {
|
|
95
|
+
const name = basename(file);
|
|
96
|
+
const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
|
|
97
|
+
if (match) {
|
|
98
|
+
return { caseName: match[1], participant: match[2] };
|
|
99
|
+
}
|
|
100
|
+
return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
|
|
101
|
+
}
|