@lchase/kiln 0.0.0-stage → 0.3.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/README.md +263 -2
- package/assets/serve.css +233 -0
- package/dist/cli.js +409 -0
- package/dist/config.js +255 -0
- package/dist/delivery.js +245 -0
- package/dist/driver.js +825 -0
- package/dist/errors.js +6 -0
- package/dist/findings.js +79 -0
- package/dist/host.js +223 -0
- package/dist/intake/file.js +29 -0
- package/dist/intake/jira.js +585 -0
- package/dist/intake/prompt.js +47 -0
- package/dist/intake/tickets.js +448 -0
- package/dist/jobs.js +154 -0
- package/dist/paths.js +34 -0
- package/dist/reconcile.js +141 -0
- package/dist/report.js +254 -0
- package/dist/schemas.js +95 -0
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/truncate-output.js +122 -0
- package/dist/workflow.js +131 -0
- package/package.json +41 -4
- package/prompts/factory/approver.md +49 -0
- package/prompts/factory/builder.md +76 -0
- package/prompts/factory/review-code.md +55 -0
- package/prompts/factory/review-security.md +50 -0
- package/prompts/factory/review-ui.md +50 -0
- package/prompts/factory/review-ux.md +48 -0
- package/prompts/factory/spec-writer.md +67 -0
- package/prompts/kiln/approver.md +33 -0
- package/prompts/kiln/build.md +38 -0
- package/prompts/kiln/code.md +26 -0
- package/prompts/kiln/investigation.md +31 -0
- package/prompts/kiln/security.md +26 -0
- package/prompts/kiln/spec.md +18 -0
- package/prompts/kiln/ui.md +26 -0
- package/prompts/kiln/ux.md +26 -0
- package/workflows/feature.yaml +239 -0
- package/workflows/investigation.yaml +67 -0
package/dist/driver.js
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
6
|
+
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
7
|
+
import { defaultHost } from "./host.js";
|
|
8
|
+
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
9
|
+
import { jobBasename, packageRoot } from "./paths.js";
|
|
10
|
+
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
11
|
+
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
12
|
+
import { JobSchema } from "./schemas.js";
|
|
13
|
+
import { materializeWorkflow, workflowInputs, workflowNeedsTestCommand } from "./workflow.js";
|
|
14
|
+
const TAG_PREFIX = "kiln:";
|
|
15
|
+
export function jobTag(jobId) {
|
|
16
|
+
return `${TAG_PREFIX}${jobId}`;
|
|
17
|
+
}
|
|
18
|
+
export function parseJobTag(tag) {
|
|
19
|
+
if (!tag.startsWith(TAG_PREFIX))
|
|
20
|
+
return undefined;
|
|
21
|
+
const id = tag.slice(TAG_PREFIX.length);
|
|
22
|
+
if (!id || id.includes("/"))
|
|
23
|
+
return undefined;
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
/** The derived view `kiln status` prints and `kiln serve` renders. */
|
|
27
|
+
export function readStatus(config, jobId, host = defaultHost()) {
|
|
28
|
+
return statusView(observe(config, host).observation, jobId);
|
|
29
|
+
}
|
|
30
|
+
export function runStatus(config, jobId, host = defaultHost()) {
|
|
31
|
+
return renderStatus(readStatus(config, jobId, host));
|
|
32
|
+
}
|
|
33
|
+
export function runTick(config, host = defaultHost()) {
|
|
34
|
+
return withTickLock(config.kilnDir, () => {
|
|
35
|
+
const world = observe(config, host);
|
|
36
|
+
const actions = decide(world.observation);
|
|
37
|
+
return apply(config, world, actions, host);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
41
|
+
const world = observe(config, host);
|
|
42
|
+
const job = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
43
|
+
if (!job) {
|
|
44
|
+
throw new Error(`No job ${jobId}.`);
|
|
45
|
+
}
|
|
46
|
+
if (job.error) {
|
|
47
|
+
throw new Error(`Job ${jobId} is invalid: ${job.error}`);
|
|
48
|
+
}
|
|
49
|
+
const state = jobState(job, world.observation);
|
|
50
|
+
if (state !== "needs-human" || !job.run) {
|
|
51
|
+
throw new Error(`Job ${jobId} is ${state}, not needs-human.`);
|
|
52
|
+
}
|
|
53
|
+
const gate = gateForChoice(job.run.gates, choice);
|
|
54
|
+
if (!gate) {
|
|
55
|
+
throw new Error(`Job ${jobId} has no open gate that accepts ${choice}.`);
|
|
56
|
+
}
|
|
57
|
+
const command = gateAnswerCommand(job.run.checkout, job.run.runId, gate, choice, job.run.title ?? job.title);
|
|
58
|
+
const result = spawnSync("sh", ["-c", command], { encoding: "utf8" });
|
|
59
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
60
|
+
if (result.status !== 0) {
|
|
61
|
+
throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
|
|
62
|
+
}
|
|
63
|
+
if (choice === "approve") {
|
|
64
|
+
recordInvestigationFindings(config, observe(config, host), jobId);
|
|
65
|
+
}
|
|
66
|
+
return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
|
|
67
|
+
}
|
|
68
|
+
function gateForChoice(gates, choice) {
|
|
69
|
+
return (gates.find((gate) => gate.choices?.includes(choice)) ??
|
|
70
|
+
gates.find((gate) => !gate.choices?.length));
|
|
71
|
+
}
|
|
72
|
+
function observe(config, host) {
|
|
73
|
+
const loaded = loadJobs(config);
|
|
74
|
+
const runs = scanRuns(config);
|
|
75
|
+
const breadcrumbs = readBreadcrumbs(config.kilnDir);
|
|
76
|
+
const jobs = [];
|
|
77
|
+
const records = new Map();
|
|
78
|
+
const breadcrumbsToClear = [];
|
|
79
|
+
const ordered = [...loaded].sort((left, right) => {
|
|
80
|
+
const byTime = (left.job?.createdAt ?? "").localeCompare(right.job?.createdAt ?? "");
|
|
81
|
+
if (byTime !== 0)
|
|
82
|
+
return byTime;
|
|
83
|
+
return left.meta.jobId.localeCompare(right.meta.jobId);
|
|
84
|
+
});
|
|
85
|
+
for (const entry of ordered) {
|
|
86
|
+
if (entry.job)
|
|
87
|
+
records.set(entry.job.id, entry.job);
|
|
88
|
+
const tag = jobTag(entry.meta.jobId);
|
|
89
|
+
const run = runs.get(tag);
|
|
90
|
+
const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
|
|
91
|
+
if (crumb && run)
|
|
92
|
+
breadcrumbsToClear.push({ path: crumb.path });
|
|
93
|
+
const childAlive = crumb ? pidAlive(crumb.pid) : false;
|
|
94
|
+
const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
|
|
95
|
+
const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
|
|
96
|
+
const repo = entry.job ? config.repos[entry.job.repo] : undefined;
|
|
97
|
+
const deliver = repo?.deliver ?? "local";
|
|
98
|
+
const delivery = entry.job && repo && deliver === "pr"
|
|
99
|
+
? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, run?.runId)
|
|
100
|
+
: undefined;
|
|
101
|
+
const launchError = entry.job && !run ? readLaunchError(config.kilnDir, entry.job.id) : undefined;
|
|
102
|
+
const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
|
|
103
|
+
? "stack needs exactly one dependency."
|
|
104
|
+
: undefined;
|
|
105
|
+
const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
|
|
106
|
+
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
107
|
+
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
108
|
+
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
109
|
+
: undefined;
|
|
110
|
+
jobs.push({
|
|
111
|
+
jobId: entry.meta.jobId,
|
|
112
|
+
repo: entry.job?.repo ?? "",
|
|
113
|
+
workflow: entry.job?.workflow ?? "",
|
|
114
|
+
title: entry.job?.title ?? "",
|
|
115
|
+
dependsOn: entry.job?.dependsOn ?? [],
|
|
116
|
+
...(entry.job?.source ? { source: entry.job.source } : {}),
|
|
117
|
+
...(specPath ? { specPath } : {}),
|
|
118
|
+
deliver,
|
|
119
|
+
...(entry.job?.stack ? { stack: true } : {}),
|
|
120
|
+
...(delivery ? { delivery } : {}),
|
|
121
|
+
...(findingsPath ? { findingsPath } : {}),
|
|
122
|
+
launching: Boolean(crumb) && !run && childAlive,
|
|
123
|
+
launchFailed: Boolean(crumb) && !run && !childAlive,
|
|
124
|
+
...(launchError ? { launchError } : {}),
|
|
125
|
+
...(logPath ? { logPath } : {}),
|
|
126
|
+
...(run ? { run } : {}),
|
|
127
|
+
...(entry.meta.error || repoError || stackError
|
|
128
|
+
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
129
|
+
: {}),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
observation: {
|
|
134
|
+
maxConcurrent: config.maxConcurrent,
|
|
135
|
+
jobs,
|
|
136
|
+
breadcrumbsToClear,
|
|
137
|
+
},
|
|
138
|
+
jobs: records,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function apply(config, world, actions, host) {
|
|
142
|
+
const launches = [];
|
|
143
|
+
const delivers = [];
|
|
144
|
+
const findings = [];
|
|
145
|
+
for (const action of actions) {
|
|
146
|
+
if (action.type === "clear-breadcrumb") {
|
|
147
|
+
rmSync(action.path, { force: true });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (action.type === "deliver") {
|
|
151
|
+
delivers.push(deliverObserved(config, world, action.jobId, host));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (action.type === "record-findings") {
|
|
155
|
+
const recorded = recordInvestigationFindings(config, world, action.jobId);
|
|
156
|
+
if (recorded.status !== "skip")
|
|
157
|
+
findings.push(recorded);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const job = world.jobs.get(action.jobId);
|
|
161
|
+
if (!job) {
|
|
162
|
+
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
163
|
+
}
|
|
164
|
+
launches.push(launchJob(config, world, job, host));
|
|
165
|
+
}
|
|
166
|
+
return renderTick(world.observation, actions, launches, delivers, findings);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
170
|
+
* A file that is already present is left as it is. A missing or incomplete
|
|
171
|
+
* artifact is reported and does not fail the tick.
|
|
172
|
+
*/
|
|
173
|
+
function recordInvestigationFindings(config, world, jobId) {
|
|
174
|
+
const job = world.jobs.get(jobId);
|
|
175
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
176
|
+
if (!job || job.workflow !== INVESTIGATION_WORKFLOW || !observed?.run || !isLanded(observed)) {
|
|
177
|
+
return { status: "skip", jobId };
|
|
178
|
+
}
|
|
179
|
+
if (observed.findingsPath)
|
|
180
|
+
return { status: "skip", jobId };
|
|
181
|
+
const markdown = readFindingsMarkdown(observed.run.checkout, observed.run.runId);
|
|
182
|
+
if (!markdown)
|
|
183
|
+
return { status: "missing", jobId };
|
|
184
|
+
const path = writeFindings(config.kilnDir, job.id, markdown);
|
|
185
|
+
return { status: "wrote", jobId, path };
|
|
186
|
+
}
|
|
187
|
+
function deliverObserved(config, world, jobId, host) {
|
|
188
|
+
const job = world.jobs.get(jobId);
|
|
189
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
190
|
+
const repo = job ? config.repos[job.repo] : undefined;
|
|
191
|
+
if (!job || !observed?.run || !repo) {
|
|
192
|
+
const detail = `Job ${jobId} is not ready to open a pull request.`;
|
|
193
|
+
return { jobId, repo: job?.repo ?? "", ok: false, detail };
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const baseBranch = pullRequestBase(world, job, repo, host);
|
|
197
|
+
const outcome = deliverJob({
|
|
198
|
+
config,
|
|
199
|
+
job,
|
|
200
|
+
checkout: repo.path,
|
|
201
|
+
runId: observed.run.runId,
|
|
202
|
+
baseBranch,
|
|
203
|
+
host,
|
|
204
|
+
});
|
|
205
|
+
return { jobId, repo: job.repo, ok: outcome.ok, detail: outcome.detail };
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
209
|
+
writeDelivery(config.kilnDir, {
|
|
210
|
+
jobId,
|
|
211
|
+
runId: observed.run.runId,
|
|
212
|
+
branch: `yak/${observed.run.runId}`,
|
|
213
|
+
base: repo.base ?? "unknown",
|
|
214
|
+
pushed: false,
|
|
215
|
+
error: message,
|
|
216
|
+
});
|
|
217
|
+
return { jobId, repo: job.repo, ok: false, detail: message };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Stacked jobs target the upstream branch. Everyone else targets the repo base. */
|
|
221
|
+
function pullRequestBase(world, job, repo, host) {
|
|
222
|
+
if (job.stack) {
|
|
223
|
+
const branch = upstreamBranch(world, job);
|
|
224
|
+
if (!branch) {
|
|
225
|
+
throw new Error(`Job ${job.id} is stacked but its upstream has no branch to target.`);
|
|
226
|
+
}
|
|
227
|
+
return branch;
|
|
228
|
+
}
|
|
229
|
+
if (repo.base)
|
|
230
|
+
return repo.base;
|
|
231
|
+
host.git.fetch(repo.path);
|
|
232
|
+
return host.git.defaultBranch(repo.path);
|
|
233
|
+
}
|
|
234
|
+
function assertStackTarget(world, job) {
|
|
235
|
+
if (!job.stack)
|
|
236
|
+
return;
|
|
237
|
+
const upstreamId = job.dependsOn?.[0];
|
|
238
|
+
if (!upstreamId)
|
|
239
|
+
return;
|
|
240
|
+
const upstream = world.jobs.get(upstreamId);
|
|
241
|
+
if (upstream?.workflow !== INVESTIGATION_WORKFLOW)
|
|
242
|
+
return;
|
|
243
|
+
throw new Error(`stack cannot target investigation job ${upstream.id}. An investigation does not land a branch.`);
|
|
244
|
+
}
|
|
245
|
+
/** Spec text for this launch. Investigation dependencies append their findings. */
|
|
246
|
+
function specHandedToYak(config, world, job) {
|
|
247
|
+
const base = readJobSpec(config.kilnDir, job);
|
|
248
|
+
const sections = [];
|
|
249
|
+
for (const depId of job.dependsOn ?? []) {
|
|
250
|
+
const dep = world.jobs.get(depId);
|
|
251
|
+
if (!dep || dep.workflow !== INVESTIGATION_WORKFLOW)
|
|
252
|
+
continue;
|
|
253
|
+
const path = findingsFilePath(config.kilnDir, dep.id);
|
|
254
|
+
if (!existsSync(path)) {
|
|
255
|
+
throw new Error(`Job ${job.id} depends on investigation ${dep.id} (${dep.title}), which has no findings file at ${path}.`);
|
|
256
|
+
}
|
|
257
|
+
sections.push({ title: dep.title, markdown: readFileSync(path, "utf8") });
|
|
258
|
+
}
|
|
259
|
+
return appendInvestigationFindings(base, sections);
|
|
260
|
+
}
|
|
261
|
+
function upstreamBranch(world, job) {
|
|
262
|
+
const upstreamId = job.dependsOn?.[0];
|
|
263
|
+
if (!upstreamId)
|
|
264
|
+
return undefined;
|
|
265
|
+
const upstream = world.observation.jobs.find((candidate) => candidate.jobId === upstreamId);
|
|
266
|
+
if (!upstream?.run)
|
|
267
|
+
return undefined;
|
|
268
|
+
return upstream.delivery?.branch ?? `yak/${upstream.run.runId}`;
|
|
269
|
+
}
|
|
270
|
+
function observedDelivery(kilnDir, host, checkout, jobId, runId) {
|
|
271
|
+
const record = readDelivery(kilnDir, jobId);
|
|
272
|
+
if (!record)
|
|
273
|
+
return undefined;
|
|
274
|
+
if (runId && record.runId !== runId)
|
|
275
|
+
return undefined;
|
|
276
|
+
let prState;
|
|
277
|
+
let merged = false;
|
|
278
|
+
let viewError;
|
|
279
|
+
if (record.prNumber) {
|
|
280
|
+
try {
|
|
281
|
+
const viewed = host.github.view(checkout, record.prNumber);
|
|
282
|
+
prState = viewed.state;
|
|
283
|
+
merged = isMerged(viewed);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
viewError = error instanceof Error ? error.message : String(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
branch: record.branch,
|
|
291
|
+
...(record.prNumber ? { prNumber: record.prNumber } : {}),
|
|
292
|
+
...(record.prUrl ? { prUrl: record.prUrl } : {}),
|
|
293
|
+
merged,
|
|
294
|
+
...(prState ? { prState } : {}),
|
|
295
|
+
...(record.error && !record.prNumber ? { error: record.error } : {}),
|
|
296
|
+
...(viewError ? { viewError } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function launchJob(config, world, job, host) {
|
|
300
|
+
const repo = config.repos[job.repo];
|
|
301
|
+
if (!repo) {
|
|
302
|
+
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
303
|
+
}
|
|
304
|
+
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
305
|
+
const testCommand = repo.test;
|
|
306
|
+
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
307
|
+
throw new Error(`Repo ${job.repo} has no test command. Set repos.${job.repo}.test. The feature workflow refuses to run an empty KILN_TEST.`);
|
|
308
|
+
}
|
|
309
|
+
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
|
|
310
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
311
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
312
|
+
const crumbPath = breadcrumbPath(config.kilnDir, job.id);
|
|
313
|
+
ensureCheckoutExcludes(repo.path);
|
|
314
|
+
let baseSha;
|
|
315
|
+
let specText;
|
|
316
|
+
try {
|
|
317
|
+
clearLaunchError(config.kilnDir, job.id);
|
|
318
|
+
assertStackTarget(world, job);
|
|
319
|
+
baseSha = resolveLaunchBase({
|
|
320
|
+
checkout: repo.path,
|
|
321
|
+
deliver: repo.deliver,
|
|
322
|
+
stack: job.stack === true,
|
|
323
|
+
...(repo.base ? { configuredBase: repo.base } : {}),
|
|
324
|
+
...(job.stack ? { upstreamBranch: upstreamBranch(world, job) } : {}),
|
|
325
|
+
git: host.git,
|
|
326
|
+
});
|
|
327
|
+
specText = specHandedToYak(config, world, job);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
331
|
+
writeLaunchError(config.kilnDir, job.id, message);
|
|
332
|
+
writeFileSync(logPath, `${message}\n`);
|
|
333
|
+
return { jobId: job.id, repo: job.repo, output: message };
|
|
334
|
+
}
|
|
335
|
+
const input = workflowInputs(sourceWorkflow, {
|
|
336
|
+
title: job.title,
|
|
337
|
+
specText,
|
|
338
|
+
runNonce: randomUUID(),
|
|
339
|
+
baseRef: baseSha,
|
|
340
|
+
});
|
|
341
|
+
const args = [
|
|
342
|
+
yakCliPath(),
|
|
343
|
+
"run",
|
|
344
|
+
workflow,
|
|
345
|
+
"--adapter",
|
|
346
|
+
config.adapter,
|
|
347
|
+
"--isolation",
|
|
348
|
+
"worktree",
|
|
349
|
+
"--from",
|
|
350
|
+
baseSha,
|
|
351
|
+
"--tag",
|
|
352
|
+
jobTag(job.id),
|
|
353
|
+
];
|
|
354
|
+
for (const [key, value] of Object.entries(input)) {
|
|
355
|
+
args.push("--input", `${key}=${value}`);
|
|
356
|
+
}
|
|
357
|
+
const env = {
|
|
358
|
+
...process.env,
|
|
359
|
+
KILN_TEST: testCommand,
|
|
360
|
+
KILN_TASK_TITLE: job.title,
|
|
361
|
+
KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
|
|
362
|
+
};
|
|
363
|
+
writeBreadcrumb(crumbPath, { jobId: job.id, pid: 0, logPath });
|
|
364
|
+
const logFd = openSync(logPath, "a");
|
|
365
|
+
try {
|
|
366
|
+
const child = spawn(process.execPath, args, {
|
|
367
|
+
cwd: repo.path,
|
|
368
|
+
detached: true,
|
|
369
|
+
stdio: ["ignore", logFd, logFd],
|
|
370
|
+
env,
|
|
371
|
+
});
|
|
372
|
+
child.unref();
|
|
373
|
+
const pid = child.pid ?? 0;
|
|
374
|
+
writeBreadcrumb(crumbPath, { jobId: job.id, pid, logPath });
|
|
375
|
+
return { jobId: job.id, repo: job.repo, output: `pid ${pid}\nlog ${logPath}` };
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
writeFileSync(logPath, `${errorMessage(error)}\n`);
|
|
379
|
+
return { jobId: job.id, repo: job.repo, output: `pid 0\nlog ${logPath}` };
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
closeSync(logFd);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function loadJobs(config) {
|
|
386
|
+
if (!existsSync(config.jobsDir))
|
|
387
|
+
return [];
|
|
388
|
+
return readdirSync(config.jobsDir)
|
|
389
|
+
.filter((name) => name.endsWith(".json"))
|
|
390
|
+
.sort()
|
|
391
|
+
.map((name) => loadJobFile(join(config.jobsDir, name)));
|
|
392
|
+
}
|
|
393
|
+
function loadJobFile(file) {
|
|
394
|
+
const fileId = basename(file, ".json");
|
|
395
|
+
let raw;
|
|
396
|
+
try {
|
|
397
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
return { meta: { jobId: fileId, error: errorMessage(error) } };
|
|
401
|
+
}
|
|
402
|
+
const parsed = JobSchema.safeParse(raw);
|
|
403
|
+
if (!parsed.success) {
|
|
404
|
+
return { meta: { jobId: fileId, error: parsed.error.message } };
|
|
405
|
+
}
|
|
406
|
+
const job = parsed.data;
|
|
407
|
+
if (`${jobBasename(job.id)}.json` !== basename(file)) {
|
|
408
|
+
return {
|
|
409
|
+
meta: {
|
|
410
|
+
jobId: job.id,
|
|
411
|
+
error: `Job file ${basename(file)} does not match id ${job.id}.`,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if ("state" in raw) {
|
|
416
|
+
return { meta: { jobId: job.id, error: "Job state is derived and must not be stored." } };
|
|
417
|
+
}
|
|
418
|
+
return { meta: { jobId: job.id }, job };
|
|
419
|
+
}
|
|
420
|
+
function scanRuns(config) {
|
|
421
|
+
const byTag = new Map();
|
|
422
|
+
for (const repo of Object.values(config.repos)) {
|
|
423
|
+
for (const runDir of listRunDirs(repo.path)) {
|
|
424
|
+
const events = readJournal(runDir);
|
|
425
|
+
const started = events.find((event) => event.t === "run.started");
|
|
426
|
+
if (!started?.tag || !parseJobTag(started.tag))
|
|
427
|
+
continue;
|
|
428
|
+
const runId = basename(runDir);
|
|
429
|
+
const status = classify(events);
|
|
430
|
+
const gateDecision = readApproveDecision(runDir);
|
|
431
|
+
const title = readRunTitle(runDir);
|
|
432
|
+
const run = {
|
|
433
|
+
runId,
|
|
434
|
+
checkout: repo.path,
|
|
435
|
+
status,
|
|
436
|
+
gates: status === "suspended" ? openGates(runDir, events) : [],
|
|
437
|
+
...(gateDecision ? { gateDecision } : {}),
|
|
438
|
+
...(title ? { title } : {}),
|
|
439
|
+
};
|
|
440
|
+
const previous = byTag.get(started.tag);
|
|
441
|
+
if (!previous || run.runId > previous.runId)
|
|
442
|
+
byTag.set(started.tag, run);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return byTag;
|
|
446
|
+
}
|
|
447
|
+
function listRunDirs(checkout) {
|
|
448
|
+
const runsDir = join(checkout, ".runs");
|
|
449
|
+
if (!existsSync(runsDir))
|
|
450
|
+
return [];
|
|
451
|
+
return readdirSync(runsDir, { withFileTypes: true })
|
|
452
|
+
.filter((entry) => entry.isDirectory())
|
|
453
|
+
.map((entry) => join(runsDir, entry.name));
|
|
454
|
+
}
|
|
455
|
+
function readJournal(runDir) {
|
|
456
|
+
const file = join(runDir, "journal.jsonl");
|
|
457
|
+
if (!existsSync(file))
|
|
458
|
+
return [];
|
|
459
|
+
const lines = readFileSync(file, "utf8").split("\n").filter((line) => line.trim());
|
|
460
|
+
const events = [];
|
|
461
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
462
|
+
const line = lines[index];
|
|
463
|
+
if (!line)
|
|
464
|
+
continue;
|
|
465
|
+
try {
|
|
466
|
+
events.push(JSON.parse(line));
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (index === lines.length - 1)
|
|
470
|
+
continue;
|
|
471
|
+
throw new Error(`Could not read ${file}: ${errorMessage(error)}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return events;
|
|
475
|
+
}
|
|
476
|
+
/** Latest `run.resumed` pid, else `run.started`. Same rule as yak's livePidFromJournal. */
|
|
477
|
+
function livePid(events) {
|
|
478
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
479
|
+
const event = events[index];
|
|
480
|
+
if (event?.t === "run.resumed" && typeof event.pid === "number")
|
|
481
|
+
return event.pid;
|
|
482
|
+
}
|
|
483
|
+
const started = events.find((event) => event.t === "run.started");
|
|
484
|
+
return typeof started?.pid === "number" ? started.pid : undefined;
|
|
485
|
+
}
|
|
486
|
+
function classify(events) {
|
|
487
|
+
const last = events.at(-1);
|
|
488
|
+
if (last?.t === "run.finished" &&
|
|
489
|
+
(last.status === "ok" || last.status === "failed" || last.status === "suspended")) {
|
|
490
|
+
return last.status;
|
|
491
|
+
}
|
|
492
|
+
const pid = livePid(events);
|
|
493
|
+
if (pid === undefined || !pidAlive(pid))
|
|
494
|
+
return "dead";
|
|
495
|
+
return "alive";
|
|
496
|
+
}
|
|
497
|
+
function readApproveDecision(runDir) {
|
|
498
|
+
const file = join(runDir, "artifacts", "approval.json");
|
|
499
|
+
if (!existsSync(file))
|
|
500
|
+
return undefined;
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
503
|
+
if (parsed.decision === "approve" || parsed.decision === "reject")
|
|
504
|
+
return parsed.decision;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// A partial artifact is not a decision.
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
function readRunTitle(runDir) {
|
|
512
|
+
const file = join(runDir, "artifacts", "input.json");
|
|
513
|
+
if (!existsSync(file))
|
|
514
|
+
return undefined;
|
|
515
|
+
try {
|
|
516
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
517
|
+
return typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : undefined;
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function openGates(runDir, events) {
|
|
524
|
+
const open = new Map();
|
|
525
|
+
for (const event of events) {
|
|
526
|
+
if (event.t === "gate.opened" && event.stepId)
|
|
527
|
+
open.set(event.stepId, true);
|
|
528
|
+
else if (event.t === "gate.answered" && event.stepId)
|
|
529
|
+
open.set(event.stepId, false);
|
|
530
|
+
}
|
|
531
|
+
const gates = [];
|
|
532
|
+
for (const [stepId, isOpen] of open) {
|
|
533
|
+
if (!isOpen)
|
|
534
|
+
continue;
|
|
535
|
+
gates.push(readGate(runDir, stepId));
|
|
536
|
+
}
|
|
537
|
+
return gates;
|
|
538
|
+
}
|
|
539
|
+
function readGate(runDir, stepId) {
|
|
540
|
+
const reviews = readReviewArtifacts(runDir);
|
|
541
|
+
const file = join(runDir, "pending", `${stepId}.request.json`);
|
|
542
|
+
if (!existsSync(file)) {
|
|
543
|
+
return { stepId, prompt: "(request file missing)", ...(reviews.length > 0 ? { reviews } : {}) };
|
|
544
|
+
}
|
|
545
|
+
const request = JSON.parse(readFileSync(file, "utf8"));
|
|
546
|
+
const prompt = request.kind === "loop-exhausted"
|
|
547
|
+
? `loop exhausted (${request.tripped ?? "budget"} at iteration ${request.iteration ?? "?"})`
|
|
548
|
+
: (request.rendered ?? stepId).split("\n")[0] ?? stepId;
|
|
549
|
+
const shape = answerShape(request.answerSchema);
|
|
550
|
+
const rendered = typeof request.rendered === "string" ? clipText(request.rendered, 2000) : undefined;
|
|
551
|
+
const artifacts = readNamedArtifacts(runDir, artifactNames(request.context?.artifacts));
|
|
552
|
+
return {
|
|
553
|
+
stepId,
|
|
554
|
+
prompt,
|
|
555
|
+
...(rendered ? { rendered } : {}),
|
|
556
|
+
...(shape ? { answerField: shape.field, choices: shape.choices } : {}),
|
|
557
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
558
|
+
...(reviews.length > 0 ? { reviews } : {}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const REVIEW_STEPS = ["security", "ux", "ui", "code"];
|
|
562
|
+
const ARTIFACT_NAME = /^[A-Za-z0-9._-]+$/;
|
|
563
|
+
const SECRET_KEY = /api[_-]?token|password|secret|authorization|credential/i;
|
|
564
|
+
/** The launch log stays after the breadcrumb is cleared. Status prints it only for a failed launch. */
|
|
565
|
+
function existingLog(kilnDir, jobId) {
|
|
566
|
+
let name;
|
|
567
|
+
try {
|
|
568
|
+
name = jobBasename(jobId);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const path = join(kilnDir, "logs", `${name}.log`);
|
|
574
|
+
return existsSync(path) ? path : undefined;
|
|
575
|
+
}
|
|
576
|
+
function artifactNames(value) {
|
|
577
|
+
if (!Array.isArray(value))
|
|
578
|
+
return [];
|
|
579
|
+
const names = [];
|
|
580
|
+
for (const entry of value) {
|
|
581
|
+
if (typeof entry !== "string" || !ARTIFACT_NAME.test(entry) || names.includes(entry))
|
|
582
|
+
continue;
|
|
583
|
+
names.push(entry);
|
|
584
|
+
}
|
|
585
|
+
return names;
|
|
586
|
+
}
|
|
587
|
+
function readNamedArtifacts(runDir, names) {
|
|
588
|
+
const found = [];
|
|
589
|
+
for (const name of names) {
|
|
590
|
+
const file = join(runDir, "artifacts", `${name}.json`);
|
|
591
|
+
if (!existsSync(file))
|
|
592
|
+
continue;
|
|
593
|
+
found.push(readArtifactSummary(file, name));
|
|
594
|
+
}
|
|
595
|
+
return found;
|
|
596
|
+
}
|
|
597
|
+
function readReviewArtifacts(runDir) {
|
|
598
|
+
const dir = join(runDir, "artifacts");
|
|
599
|
+
if (!existsSync(dir))
|
|
600
|
+
return [];
|
|
601
|
+
let files;
|
|
602
|
+
try {
|
|
603
|
+
files = readdirSync(dir);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
const best = new Map();
|
|
609
|
+
for (const file of files) {
|
|
610
|
+
const match = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/.exec(file);
|
|
611
|
+
if (!match?.[1])
|
|
612
|
+
continue;
|
|
613
|
+
const iter = match[2] ? Number(match[2]) : 0;
|
|
614
|
+
const previous = best.get(match[1]);
|
|
615
|
+
if (!previous || iter >= previous.iter)
|
|
616
|
+
best.set(match[1], { iter, file });
|
|
617
|
+
}
|
|
618
|
+
const found = [];
|
|
619
|
+
for (const step of REVIEW_STEPS) {
|
|
620
|
+
const picked = best.get(step);
|
|
621
|
+
if (!picked)
|
|
622
|
+
continue;
|
|
623
|
+
found.push(readArtifactSummary(join(dir, picked.file), step));
|
|
624
|
+
}
|
|
625
|
+
return found;
|
|
626
|
+
}
|
|
627
|
+
function readArtifactSummary(file, name) {
|
|
628
|
+
if (!existsSync(file))
|
|
629
|
+
return { name, summary: "(missing artifact)" };
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
632
|
+
return { name, summary: summarizeArtifact(name, parsed) };
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return { name, summary: "(unreadable artifact)" };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function summarizeArtifact(name, value) {
|
|
639
|
+
const clean = redactSecrets(value);
|
|
640
|
+
if (name === "input" && isRecord(clean)) {
|
|
641
|
+
const title = clean.title;
|
|
642
|
+
return typeof title === "string" && title.trim() ? `title: ${clipText(title, 120)}` : "(no title)";
|
|
643
|
+
}
|
|
644
|
+
if (isRecord(clean)) {
|
|
645
|
+
const head = typeof clean.decision === "string" ? clean.decision : typeof clean.verdict === "string" ? clean.verdict : undefined;
|
|
646
|
+
const detail = typeof clean.reason === "string" ? clean.reason : typeof clean.findings === "string" ? clean.findings : undefined;
|
|
647
|
+
if (head)
|
|
648
|
+
return clipText(detail ? `${head} — ${detail}` : head, 280);
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
return clipText(JSON.stringify(clean), 280);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return "(unreadable artifact)";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function redactSecrets(value) {
|
|
658
|
+
if (Array.isArray(value))
|
|
659
|
+
return value.map(redactSecrets);
|
|
660
|
+
if (!isRecord(value))
|
|
661
|
+
return value;
|
|
662
|
+
const out = {};
|
|
663
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
664
|
+
out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
function isRecord(value) {
|
|
669
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
670
|
+
}
|
|
671
|
+
function clipText(text, max) {
|
|
672
|
+
const trimmed = text.trim();
|
|
673
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}...`;
|
|
674
|
+
}
|
|
675
|
+
function answerShape(schema) {
|
|
676
|
+
if (typeof schema !== "object" || schema === null || !("properties" in schema))
|
|
677
|
+
return undefined;
|
|
678
|
+
const properties = schema.properties;
|
|
679
|
+
if (typeof properties !== "object" || properties === null)
|
|
680
|
+
return undefined;
|
|
681
|
+
const required = "required" in schema && Array.isArray(schema.required)
|
|
682
|
+
? schema.required.filter((item) => typeof item === "string")
|
|
683
|
+
: [];
|
|
684
|
+
for (const key of required) {
|
|
685
|
+
const prop = properties[key];
|
|
686
|
+
if (typeof prop !== "object" || prop === null || !("enum" in prop) || !Array.isArray(prop.enum))
|
|
687
|
+
continue;
|
|
688
|
+
const choices = prop.enum.filter((value) => typeof value === "string");
|
|
689
|
+
if (choices.length > 0)
|
|
690
|
+
return { field: key, choices };
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
function breadcrumbPath(kilnDir, jobId) {
|
|
695
|
+
return join(kilnDir, "launching", `${jobBasename(jobId)}.json`);
|
|
696
|
+
}
|
|
697
|
+
function writeBreadcrumb(path, body) {
|
|
698
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
699
|
+
writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`);
|
|
700
|
+
}
|
|
701
|
+
function readBreadcrumbs(kilnDir) {
|
|
702
|
+
const root = join(kilnDir, "launching");
|
|
703
|
+
if (!existsSync(root))
|
|
704
|
+
return [];
|
|
705
|
+
const found = [];
|
|
706
|
+
for (const name of readdirSync(root)) {
|
|
707
|
+
if (!name.endsWith(".json"))
|
|
708
|
+
continue;
|
|
709
|
+
const path = join(root, name);
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
712
|
+
if (!parsed.jobId)
|
|
713
|
+
continue;
|
|
714
|
+
found.push({
|
|
715
|
+
path,
|
|
716
|
+
jobId: parsed.jobId,
|
|
717
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
718
|
+
logPath: typeof parsed.logPath === "string" ? parsed.logPath : "",
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// An unreadable crumb is left for a person. It does not count as a job.
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return found;
|
|
726
|
+
}
|
|
727
|
+
/** The checkout's git metadata must not show up in `git status` or a later `git add`. */
|
|
728
|
+
function ensureCheckoutExcludes(checkout) {
|
|
729
|
+
const gitDir = gitCommonDir(checkout);
|
|
730
|
+
if (!gitDir)
|
|
731
|
+
return;
|
|
732
|
+
const info = join(gitDir, "info");
|
|
733
|
+
mkdirSync(info, { recursive: true });
|
|
734
|
+
const exclude = join(info, "exclude");
|
|
735
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
736
|
+
const present = new Set(current.split("\n").map((line) => line.trim()));
|
|
737
|
+
const missing = [".runs/", ".yak/"].filter((entry) => !present.has(entry));
|
|
738
|
+
if (missing.length === 0)
|
|
739
|
+
return;
|
|
740
|
+
const suffix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
741
|
+
writeFileSync(exclude, `${current}${suffix}${missing.join("\n")}\n`);
|
|
742
|
+
}
|
|
743
|
+
/** Main git dir, including when `.git` is a file (a linked worktree). */
|
|
744
|
+
function gitCommonDir(checkout) {
|
|
745
|
+
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
|
|
746
|
+
cwd: checkout,
|
|
747
|
+
encoding: "utf8",
|
|
748
|
+
});
|
|
749
|
+
if (result.status !== 0)
|
|
750
|
+
return undefined;
|
|
751
|
+
const printed = result.stdout.trim();
|
|
752
|
+
if (!printed)
|
|
753
|
+
return undefined;
|
|
754
|
+
return isAbsolute(printed) ? printed : join(checkout, printed);
|
|
755
|
+
}
|
|
756
|
+
function withTickLock(kilnDir, fn) {
|
|
757
|
+
mkdirSync(kilnDir, { recursive: true });
|
|
758
|
+
const lockPath = join(kilnDir, "tick.lock");
|
|
759
|
+
acquireLock(lockPath);
|
|
760
|
+
try {
|
|
761
|
+
return fn();
|
|
762
|
+
}
|
|
763
|
+
finally {
|
|
764
|
+
releaseLock(lockPath);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Publish the pid with link(2) so a reader never observes the lock file
|
|
769
|
+
* before its contents are complete. open(O_EXCL) plus a later write leaves
|
|
770
|
+
* a window where the file exists and is empty.
|
|
771
|
+
*/
|
|
772
|
+
function acquireLock(lockPath) {
|
|
773
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
774
|
+
const tmp = `${lockPath}.${process.pid}.tmp`;
|
|
775
|
+
writeFileSync(tmp, String(process.pid));
|
|
776
|
+
try {
|
|
777
|
+
linkSync(tmp, lockPath);
|
|
778
|
+
rmSync(tmp, { force: true });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
rmSync(tmp, { force: true });
|
|
783
|
+
if (!isErrno(error, "EEXIST"))
|
|
784
|
+
throw error;
|
|
785
|
+
let pid = Number.NaN;
|
|
786
|
+
try {
|
|
787
|
+
pid = Number(readFileSync(lockPath, "utf8").trim());
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (pidAlive(pid)) {
|
|
793
|
+
throw new Error(`Another kiln tick is running (pid ${pid}).`);
|
|
794
|
+
}
|
|
795
|
+
rmSync(lockPath, { force: true });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
throw new Error(`Could not acquire tick lock ${lockPath}.`);
|
|
799
|
+
}
|
|
800
|
+
function releaseLock(lockPath) {
|
|
801
|
+
try {
|
|
802
|
+
if (readFileSync(lockPath, "utf8").trim() === String(process.pid))
|
|
803
|
+
rmSync(lockPath, { force: true });
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
// The lock file is already gone.
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function pidAlive(pid) {
|
|
810
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
811
|
+
return false;
|
|
812
|
+
try {
|
|
813
|
+
process.kill(pid, 0);
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
return isErrno(error, "EPERM");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function isErrno(error, code) {
|
|
821
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
822
|
+
}
|
|
823
|
+
function errorMessage(error) {
|
|
824
|
+
return error instanceof Error ? error.message : String(error);
|
|
825
|
+
}
|