rubrkit 0.1.1 → 0.4.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/src/sdk.js CHANGED
@@ -1,443 +1,556 @@
1
- // @ts-check
2
-
3
- import { DEFAULT_API_URL, normalizeApiUrl } from './config.js';
4
-
5
- const TERMINAL_JOB_STATES = new Set(['succeeded', 'failed', 'cancelled', 'paused']);
6
-
7
- /**
8
- * @typedef {{
9
- * apiKey?: string | null,
10
- * apiUrl?: string,
11
- * fetchImpl?: typeof fetch,
12
- * userAgent?: string,
13
- * retryReads?: number
14
- * }} RubrkitOptions
15
- *
16
- * @typedef {{
17
- * intervalMs?: number,
18
- * timeoutMs?: number,
19
- * signal?: AbortSignal,
20
- * onProgress?: (job: Record<string, any>) => void
21
- * }} JobWaitOptions
22
- */
23
-
24
- export class RubrkitError extends Error {
25
- /**
26
- * @param {string} message
27
- * @param {{ code?: string, details?: unknown }} [options]
28
- */
29
- constructor(message, { code = 'rubrkit_error', details = undefined } = {}) {
30
- super(message);
31
- this.name = 'RubrkitError';
32
- this.code = code;
33
- this.details = details;
34
- }
35
- }
36
-
37
- export class RubrkitApiError extends RubrkitError {
38
- /**
39
- * @param {string} message
40
- * @param {{ code?: string, status?: number, requestId?: string | null, details?: unknown }} [options]
41
- */
42
- constructor(message, { code = 'api_request_failed', status = 0, requestId = null, details = undefined } = {}) {
43
- super(message, { code, details });
44
- this.name = 'RubrkitApiError';
45
- this.status = status;
46
- this.requestId = requestId;
47
- }
48
- }
49
-
50
- export class RubrkitNetworkError extends RubrkitError {
51
- /**
52
- * @param {string} message
53
- * @param {{ cause?: unknown }} [options]
54
- */
55
- constructor(message, { cause = undefined } = {}) {
56
- super(message, { code: 'network_error', details: cause });
57
- this.name = 'RubrkitNetworkError';
58
- this.cause = cause;
59
- }
60
- }
61
-
62
- export class Rubrkit {
63
- /**
64
- * @param {RubrkitOptions} [options]
65
- */
66
- constructor({
67
- apiKey = process.env.RUBRKIT_API_KEY ?? null,
68
- apiUrl = process.env.RUBRKIT_API_URL ?? DEFAULT_API_URL,
69
- fetchImpl = globalThis.fetch,
70
- userAgent = 'rubrkit-sdk/0.0.0-local',
71
- retryReads = 0,
72
- } = {}) {
73
- this.apiKey = apiKey;
74
- this.apiUrl = normalizeApiUrl(apiUrl);
75
- this.fetchImpl = fetchImpl;
76
- this.userAgent = userAgent;
77
- this.retryReads = Math.max(0, Number(retryReads) || 0);
78
-
79
- this.me = {
80
- get: () => this.request('/me'),
81
- };
82
-
83
- this.artifactBundles = {
84
- list: (params = {}) => this.request('/artifact-bundles', { query: { status: 'active', limit: 100, ...params } }),
85
- create: (params = {}) => this.request('/artifact-bundles', { method: 'POST', body: params }),
86
- get: (id) => this.request(`/artifact-bundles/${encodePath(id)}`),
87
- };
88
-
89
- this.artifacts = {
90
- list: (params = {}) => {
91
- const artifactBundleId = requireParam(params, 'artifactBundleId');
92
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
93
- query: omit(params, ['artifactBundleId']),
94
- });
95
- },
96
- pull: (params = {}) => {
97
- const artifactBundleId = requireParam(params, 'artifactBundleId');
98
- const artifactId = params.artifactId ?? params.fileId;
99
-
100
- if (artifactId) {
101
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files/${encodePath(artifactId)}`);
102
- }
103
-
104
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
105
- query: omit(params, ['artifactBundleId']),
106
- });
107
- },
108
- upload: (params = {}) => {
109
- const artifactBundleId = requireParam(params, 'artifactBundleId');
110
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
111
- method: 'POST',
112
- body: omit(params, ['artifactBundleId']),
113
- });
114
- },
115
- test: (params = {}) => this.startArtifactTest(params),
116
- };
117
-
118
- this.audits = {
119
- run: (params = {}) => {
120
- const artifactBundleId = requireParam(params, 'artifactBundleId');
121
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/audits`, {
122
- method: 'POST',
123
- body: normalizeAuditBody(omit(params, ['artifactBundleId'])),
124
- });
125
- },
126
- };
127
-
128
- this.evals = {
129
- run: (params = {}) => {
130
- const artifactBundleId = requireParam(params, 'artifactBundleId');
131
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/evals`, {
132
- method: 'POST',
133
- body: omit(params, ['artifactBundleId']),
134
- });
135
- },
136
- };
137
-
138
- this.rubrFlow = {
139
- convert: (params = {}) => {
140
- const artifactBundleId = requireParam(params, 'artifactBundleId');
141
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/rubr-flow/conversions`, {
142
- method: 'POST',
143
- body: omit(params, ['artifactBundleId']),
144
- });
145
- },
146
- };
147
-
148
- this.jobs = {
149
- get: (id) => this.request(`/jobs/${encodePath(id)}`),
150
- wait: (id, options = {}) => this.waitForJob(id, options),
151
- };
152
-
153
- this.proofReports = {
154
- get: (idOrParams, options = {}) => {
155
- const params = typeof idOrParams === 'object' && idOrParams !== null ? idOrParams : { proofReportId: idOrParams, ...options };
156
- const artifactBundleId = requireParam(params, 'artifactBundleId');
157
- const proofReportId = requireParam(params, 'proofReportId');
158
- return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/proof-reports/${encodePath(proofReportId)}`);
159
- },
160
- };
161
- }
162
-
163
- /**
164
- * @param {string} path
165
- * @param {{ method?: string, query?: Record<string, unknown>, body?: unknown, signal?: AbortSignal }} [options]
166
- */
167
- async request(path, { method = 'GET', query = {}, body = undefined, signal = undefined } = {}) {
168
- const url = buildUrl(this.apiUrl, path, query);
169
- /** @type {Record<string, string>} */
170
- const headers = {
171
- accept: 'application/json',
172
- 'user-agent': this.userAgent,
173
- };
174
-
175
- if (this.apiKey) {
176
- headers.authorization = `Bearer ${this.apiKey}`;
177
- }
178
-
179
- if (body !== undefined) {
180
- headers['content-type'] = 'application/json';
181
- }
182
-
183
- const attempts = method === 'GET' ? this.retryReads + 1 : 1;
184
- let lastError;
185
-
186
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
187
- try {
188
- const response = await this.fetchImpl(url, {
189
- method,
190
- headers,
191
- body: body === undefined ? undefined : JSON.stringify(body),
192
- signal,
193
- });
194
- return await parseResponse(response);
195
- } catch (error) {
196
- lastError = error;
197
-
198
- if (error instanceof RubrkitApiError || attempt >= attempts) {
199
- throw error;
200
- }
201
- }
202
- }
203
-
204
- throw lastError;
205
- }
206
-
207
- /**
208
- * @param {Record<string, any>} [params]
209
- */
210
- async startArtifactTest(params = {}) {
211
- const files = Array.isArray(params.files) ? params.files : [];
212
- let artifactBundleId = typeof params.artifactBundleId === 'string' && params.artifactBundleId.trim() ? params.artifactBundleId.trim() : null;
213
- let artifactBundle = null;
214
- let upload = null;
215
-
216
- if (files.length > 0) {
217
- if (!artifactBundleId) {
218
- const created = await this.artifactBundles.create({
219
- name: params.name ?? inferArtifactTestBundleName(files),
220
- description: params.description ?? 'Created by a Rubrkit SDK or CLI remote test run.',
221
- customRubric: params.customRubric,
222
- settings: params.settings,
223
- });
224
- const createdRecord = asRecord(created);
225
- artifactBundle = createdRecord.artifactBundle ?? createdRecord;
226
- artifactBundleId = typeof asRecord(artifactBundle).id === 'string' ? asRecord(artifactBundle).id : null;
227
- }
228
-
229
- if (!artifactBundleId) {
230
- throw new RubrkitError('Rubrkit API response did not include an artifact bundle ID.', { code: 'missing_artifact_bundle_id' });
231
- }
232
-
233
- upload = await this.artifacts.upload({
234
- artifactBundleId,
235
- files,
236
- });
237
- }
238
-
239
- if (!artifactBundleId) {
240
- artifactBundleId = requireParam(params, 'artifactBundleId');
241
- }
242
-
243
- const started = await this.audits.run({
244
- artifactBundleId,
245
- ...pickAuditParams(params),
246
- });
247
-
248
- return {
249
- ...started,
250
- artifactBundleId,
251
- artifactBundle,
252
- upload,
253
- composedFrom: files.length > 0
254
- ? ['artifact-bundles.create', 'artifact-bundles.files.upload', 'artifact-bundles.audits.create']
255
- : ['artifact-bundles.audits.create'],
256
- };
257
- }
258
-
259
- /**
260
- * @param {string} id
261
- * @param {JobWaitOptions} [options]
262
- */
263
- async waitForJob(id, { intervalMs = 1000, timeoutMs = 10 * 60 * 1000, signal = undefined, onProgress = undefined } = {}) {
264
- const startedAt = Date.now();
265
- let lastPhase = null;
266
-
267
- while (true) {
268
- if (signal?.aborted) {
269
- throw new RubrkitError('Job wait was aborted.', { code: 'aborted' });
270
- }
271
-
272
- const data = await this.jobs.get(id);
273
- const job = data.job ?? data;
274
-
275
- if (job.phase !== lastPhase || TERMINAL_JOB_STATES.has(job.state)) {
276
- onProgress?.(job);
277
- lastPhase = job.phase;
278
- }
279
-
280
- if (TERMINAL_JOB_STATES.has(job.state)) {
281
- return job;
282
- }
283
-
284
- if (Date.now() - startedAt > timeoutMs) {
285
- throw new RubrkitError(`Timed out waiting for Rubrkit job ${id}.`, { code: 'job_wait_timeout', details: { jobId: id } });
286
- }
287
-
288
- await sleep(intervalMs);
289
- }
290
- }
291
- }
292
-
293
- /**
294
- * @param {Response} response
295
- */
296
- async function parseResponse(response) {
297
- const text = await response.text();
298
- const body = text ? parseJson(text) : {};
299
- const requestId = body && typeof body === 'object' && typeof body.requestId === 'string' ? body.requestId : null;
300
-
301
- if (!response.ok) {
302
- const error = body && typeof body === 'object' && 'error' in body ? body.error : {};
303
- const message =
304
- error && typeof error === 'object' && typeof error.message === 'string'
305
- ? error.message
306
- : `Rubrkit API request failed with HTTP ${response.status}.`;
307
- const code = error && typeof error === 'object' && typeof error.code === 'string' ? error.code : 'api_request_failed';
308
-
309
- throw new RubrkitApiError(message, {
310
- code,
311
- status: response.status,
312
- requestId,
313
- details: error && typeof error === 'object' ? error.details : undefined,
314
- });
315
- }
316
-
317
- return body && typeof body === 'object' && 'data' in body ? body.data : body;
318
- }
319
-
320
- /**
321
- * @param {string} text
322
- */
323
- function parseJson(text) {
324
- try {
325
- return JSON.parse(text);
326
- } catch (error) {
327
- throw new RubrkitApiError('Rubrkit API returned invalid JSON.', {
328
- code: 'invalid_api_response',
329
- details: error,
330
- });
331
- }
332
- }
333
-
334
- /**
335
- * @param {string} apiUrl
336
- * @param {string} path
337
- * @param {Record<string, unknown>} query
338
- */
339
- function buildUrl(apiUrl, path, query) {
340
- const url = new URL(`${apiUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
341
-
342
- for (const [key, value] of Object.entries(query)) {
343
- if (value === undefined || value === null || value === false) {
344
- continue;
345
- }
346
-
347
- url.searchParams.set(key, String(value));
348
- }
349
-
350
- return url.toString();
351
- }
352
-
353
- /**
354
- * @param {unknown} value
355
- */
356
- function encodePath(value) {
357
- return encodeURIComponent(String(value));
358
- }
359
-
360
- /**
361
- * @param {Record<string, any>} params
362
- * @param {string} name
363
- */
364
- function requireParam(params, name) {
365
- const value = params[name];
366
- if (typeof value !== 'string' || !value.trim()) {
367
- throw new RubrkitError(`Missing required SDK parameter "${name}".`, { code: 'missing_sdk_parameter', details: { name } });
368
- }
369
-
370
- return value.trim();
371
- }
372
-
373
- /**
374
- * @param {unknown} value
375
- * @returns {Record<string, any>}
376
- */
377
- function asRecord(value) {
378
- return value && typeof value === 'object' && !Array.isArray(value) ? /** @type {Record<string, any>} */ (value) : {};
379
- }
380
-
381
- /**
382
- * @param {Record<string, any>} params
383
- */
384
- function pickAuditParams(params) {
385
- const picked = {};
386
-
387
- for (const key of ['artifact', 'artifactType', 'targetFileId', 'targetVersionNumber', 'artifactBundleVersionNumber', 'rubric', 'rubricKey', 'noAi']) {
388
- if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
389
- picked[key] = params[key];
390
- }
391
- }
392
-
393
- return normalizeAuditBody(picked);
394
- }
395
-
396
- /**
397
- * @param {Record<string, any>} body
398
- */
399
- function normalizeAuditBody(body) {
400
- const normalized = { ...body };
401
-
402
- if (normalized.rubric !== undefined && normalized.rubricKey === undefined) {
403
- normalized.rubricKey = normalized.rubric;
404
- }
405
-
406
- delete normalized.rubric;
407
- delete normalized.target;
408
- delete normalized.ci;
409
- delete normalized.files;
410
- delete normalized.name;
411
- delete normalized.description;
412
- delete normalized.customRubric;
413
- delete normalized.settings;
414
-
415
- return normalized;
416
- }
417
-
418
- /**
419
- * @param {Array<Record<string, any>>} files
420
- */
421
- function inferArtifactTestBundleName(files) {
422
- const firstPath = typeof files[0]?.path === 'string' && files[0].path.trim() ? files[0].path.trim() : 'artifact';
423
- const suffix = files.length > 1 ? ` and ${files.length - 1} more` : '';
424
- const name = `CLI test: ${firstPath}${suffix}`;
425
-
426
- return name.length <= 120 ? name : `${name.slice(0, 117)}...`;
427
- }
428
-
429
- /**
430
- * @param {Record<string, any>} params
431
- * @param {string[]} keys
432
- */
433
- function omit(params, keys) {
434
- const blocked = new Set(keys);
435
- return Object.fromEntries(Object.entries(params).filter(([key]) => !blocked.has(key)));
436
- }
437
-
438
- /**
439
- * @param {number} ms
440
- */
441
- function sleep(ms) {
442
- return new Promise(resolve => setTimeout(resolve, ms));
443
- }
1
+ // @ts-check
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { DEFAULT_API_URL, normalizeApiUrl } from './config.js';
5
+
6
+ const TERMINAL_JOB_STATES = new Set(['succeeded', 'failed', 'cancelled', 'paused']);
7
+
8
+ /**
9
+ * Content hash matching the server's `computeContentHash` (sha256 of the UTF-8
10
+ * content bytes, prefixed `sha256:`). Used to detect which files changed.
11
+ *
12
+ * @param {string} content
13
+ * @returns {string}
14
+ */
15
+ function contentHashOf(content) {
16
+ return `sha256:${createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex')}`;
17
+ }
18
+
19
+ /**
20
+ * Extracts the file array from a list-files API response, tolerant of the
21
+ * `{ data: { files } }` / `{ files } / [...]` envelope shapes.
22
+ *
23
+ * @param {unknown} listed
24
+ * @returns {Array<{ id: string, path: string, contentHash?: string }>}
25
+ */
26
+ function extractBundleFiles(listed) {
27
+ const root = /** @type {any} */ (listed) || {};
28
+ const data = root.data ?? root.result ?? root;
29
+ const files = data?.files ?? data?.artifactFiles ?? (Array.isArray(data) ? data : []);
30
+ return Array.isArray(files) ? files : [];
31
+ }
32
+
33
+ /**
34
+ * @typedef {{
35
+ * apiKey?: string | null,
36
+ * apiUrl?: string,
37
+ * fetchImpl?: typeof fetch,
38
+ * userAgent?: string,
39
+ * retryReads?: number
40
+ * }} RubrkitOptions
41
+ *
42
+ * @typedef {{
43
+ * intervalMs?: number,
44
+ * timeoutMs?: number,
45
+ * signal?: AbortSignal,
46
+ * onProgress?: (job: Record<string, any>) => void
47
+ * }} JobWaitOptions
48
+ */
49
+
50
+ export class RubrkitError extends Error {
51
+ /**
52
+ * @param {string} message
53
+ * @param {{ code?: string, details?: unknown }} [options]
54
+ */
55
+ constructor(message, { code = 'rubrkit_error', details = undefined } = {}) {
56
+ super(message);
57
+ this.name = 'RubrkitError';
58
+ this.code = code;
59
+ this.details = details;
60
+ }
61
+ }
62
+
63
+ export class RubrkitApiError extends RubrkitError {
64
+ /**
65
+ * @param {string} message
66
+ * @param {{ code?: string, status?: number, requestId?: string | null, details?: unknown }} [options]
67
+ */
68
+ constructor(message, { code = 'api_request_failed', status = 0, requestId = null, details = undefined } = {}) {
69
+ super(message, { code, details });
70
+ this.name = 'RubrkitApiError';
71
+ this.status = status;
72
+ this.requestId = requestId;
73
+ }
74
+ }
75
+
76
+ export class RubrkitNetworkError extends RubrkitError {
77
+ /**
78
+ * @param {string} message
79
+ * @param {{ cause?: unknown }} [options]
80
+ */
81
+ constructor(message, { cause = undefined } = {}) {
82
+ super(message, { code: 'network_error', details: cause });
83
+ this.name = 'RubrkitNetworkError';
84
+ this.cause = cause;
85
+ }
86
+ }
87
+
88
+ export class Rubrkit {
89
+ /**
90
+ * @param {RubrkitOptions} [options]
91
+ */
92
+ constructor({
93
+ apiKey = process.env.RUBRKIT_API_KEY ?? null,
94
+ apiUrl = process.env.RUBRKIT_API_URL ?? DEFAULT_API_URL,
95
+ fetchImpl = globalThis.fetch,
96
+ userAgent = 'rubrkit-sdk/0.0.0-local',
97
+ retryReads = 0,
98
+ } = {}) {
99
+ this.apiKey = apiKey;
100
+ this.apiUrl = normalizeApiUrl(apiUrl);
101
+ this.fetchImpl = fetchImpl;
102
+ this.userAgent = userAgent;
103
+ this.retryReads = Math.max(0, Number(retryReads) || 0);
104
+
105
+ this.me = {
106
+ get: () => this.request('/me'),
107
+ };
108
+
109
+ this.artifactBundles = {
110
+ list: (params = {}) => this.request('/artifact-bundles', { query: { status: 'active', limit: 100, ...params } }),
111
+ create: (params = {}) => this.request('/artifact-bundles', { method: 'POST', body: params }),
112
+ get: (id) => this.request(`/artifact-bundles/${encodePath(id)}`),
113
+ };
114
+
115
+ this.artifacts = {
116
+ list: (params = {}) => {
117
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
118
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
119
+ query: omit(params, ['artifactBundleId']),
120
+ });
121
+ },
122
+ pull: (params = {}) => {
123
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
124
+ const artifactId = params.artifactId ?? params.fileId;
125
+
126
+ if (artifactId) {
127
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files/${encodePath(artifactId)}`);
128
+ }
129
+
130
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
131
+ query: omit(params, ['artifactBundleId']),
132
+ });
133
+ },
134
+ upload: (params = {}) => {
135
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
136
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files`, {
137
+ method: 'POST',
138
+ body: omit(params, ['artifactBundleId']),
139
+ });
140
+ },
141
+ update: (params = {}) => {
142
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
143
+ const fileId = requireParam(params, 'fileId');
144
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files/${encodePath(fileId)}`, {
145
+ method: 'PUT',
146
+ body: omit(params, ['artifactBundleId', 'fileId']),
147
+ });
148
+ },
149
+ remove: (params = {}) => {
150
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
151
+ const fileId = requireParam(params, 'fileId');
152
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/files/${encodePath(fileId)}`, {
153
+ method: 'DELETE',
154
+ });
155
+ },
156
+ test: (params = {}) => this.startArtifactTest(params),
157
+ };
158
+
159
+ this.audits = {
160
+ run: (params = {}) => {
161
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
162
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/audits`, {
163
+ method: 'POST',
164
+ body: normalizeAuditBody(omit(params, ['artifactBundleId'])),
165
+ });
166
+ },
167
+ apply: (params = {}) => {
168
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
169
+ const auditRunId = requireParam(params, 'auditRunId');
170
+ return this.request(
171
+ `/artifact-bundles/${encodePath(artifactBundleId)}/audits/${encodePath(auditRunId)}/apply`,
172
+ { method: 'POST', body: omit(params, ['artifactBundleId', 'auditRunId']) },
173
+ );
174
+ },
175
+ };
176
+
177
+ this.evals = {
178
+ run: (params = {}) => {
179
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
180
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/evals`, {
181
+ method: 'POST',
182
+ body: omit(params, ['artifactBundleId']),
183
+ });
184
+ },
185
+ };
186
+
187
+ this.rubrFlow = {
188
+ convert: (params = {}) => {
189
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
190
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/rubr-flow/conversions`, {
191
+ method: 'POST',
192
+ body: omit(params, ['artifactBundleId']),
193
+ });
194
+ },
195
+ };
196
+
197
+ this.jobs = {
198
+ get: (id) => this.request(`/jobs/${encodePath(id)}`),
199
+ wait: (id, options = {}) => this.waitForJob(id, options),
200
+ };
201
+
202
+ this.proofReports = {
203
+ get: (idOrParams, options = {}) => {
204
+ const params = typeof idOrParams === 'object' && idOrParams !== null ? idOrParams : { proofReportId: idOrParams, ...options };
205
+ const artifactBundleId = requireParam(params, 'artifactBundleId');
206
+ const proofReportId = requireParam(params, 'proofReportId');
207
+ return this.request(`/artifact-bundles/${encodePath(artifactBundleId)}/proof-reports/${encodePath(proofReportId)}`);
208
+ },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * @param {string} path
214
+ * @param {{ method?: string, query?: Record<string, unknown>, body?: unknown, signal?: AbortSignal }} [options]
215
+ */
216
+ async request(path, { method = 'GET', query = {}, body = undefined, signal = undefined } = {}) {
217
+ const url = buildUrl(this.apiUrl, path, query);
218
+ /** @type {Record<string, string>} */
219
+ const headers = {
220
+ accept: 'application/json',
221
+ 'user-agent': this.userAgent,
222
+ };
223
+
224
+ if (this.apiKey) {
225
+ headers.authorization = `Bearer ${this.apiKey}`;
226
+ }
227
+
228
+ if (body !== undefined) {
229
+ headers['content-type'] = 'application/json';
230
+ }
231
+
232
+ const attempts = method === 'GET' ? this.retryReads + 1 : 1;
233
+ let lastError;
234
+
235
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
236
+ try {
237
+ const response = await this.fetchImpl(url, {
238
+ method,
239
+ headers,
240
+ body: body === undefined ? undefined : JSON.stringify(body),
241
+ signal,
242
+ });
243
+ return await parseResponse(response);
244
+ } catch (error) {
245
+ lastError = error;
246
+
247
+ if (error instanceof RubrkitApiError || attempt >= attempts) {
248
+ throw error;
249
+ }
250
+ }
251
+ }
252
+
253
+ throw lastError;
254
+ }
255
+
256
+ /**
257
+ * @param {Record<string, any>} [params]
258
+ */
259
+ async startArtifactTest(params = {}) {
260
+ const files = Array.isArray(params.files) ? params.files : [];
261
+ let artifactBundleId = typeof params.artifactBundleId === 'string' && params.artifactBundleId.trim() ? params.artifactBundleId.trim() : null;
262
+ let artifactBundle = null;
263
+ let upload = null;
264
+
265
+ if (files.length > 0) {
266
+ const reuseExistingBundle = Boolean(artifactBundleId);
267
+
268
+ if (!artifactBundleId) {
269
+ const created = await this.artifactBundles.create({
270
+ name: params.name ?? inferArtifactTestBundleName(files),
271
+ description: params.description ?? 'Created by a Rubrkit SDK or CLI remote test run.',
272
+ customRubric: params.customRubric,
273
+ settings: params.settings,
274
+ });
275
+ const createdRecord = asRecord(created);
276
+ artifactBundle = createdRecord.artifactBundle ?? createdRecord;
277
+ artifactBundleId = typeof asRecord(artifactBundle).id === 'string' ? asRecord(artifactBundle).id : null;
278
+ }
279
+
280
+ if (!artifactBundleId) {
281
+ throw new RubrkitError('Rubrkit API response did not include an artifact bundle ID.', { code: 'missing_artifact_bundle_id' });
282
+ }
283
+
284
+ // A brand-new bundle is empty, so a plain create-upload is correct. An
285
+ // existing (pinned) bundle is synced: changed files are updated in place,
286
+ // new files added, and files no longer present are removed — so repeated
287
+ // runs don't conflict on already-present paths.
288
+ upload = reuseExistingBundle
289
+ ? await this.syncArtifactBundleFiles({ artifactBundleId, files, message: params.message })
290
+ : await this.artifacts.upload({ artifactBundleId, files });
291
+ }
292
+
293
+ if (!artifactBundleId) {
294
+ artifactBundleId = requireParam(params, 'artifactBundleId');
295
+ }
296
+
297
+ const started = await this.audits.run({
298
+ artifactBundleId,
299
+ ...pickAuditParams(params),
300
+ });
301
+
302
+ return {
303
+ ...started,
304
+ artifactBundleId,
305
+ artifactBundle,
306
+ upload,
307
+ composedFrom: files.length > 0
308
+ ? ['artifact-bundles.create', 'artifact-bundles.files.upload', 'artifact-bundles.audits.create']
309
+ : ['artifact-bundles.audits.create'],
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Mirrors a local file set into an existing artifact bundle: updates files
315
+ * whose content changed, creates new ones, and deletes files no longer
316
+ * present. Unchanged files (matching content hash) are left untouched, so
317
+ * re-running on the same content is a no-op instead of a path conflict.
318
+ *
319
+ * @param {{ artifactBundleId: string, files: Array<{ path: string, content: string, artifactType?: string }>, message?: string }} params
320
+ * @returns {Promise<{ created: string[], updated: string[], deleted: string[], unchanged: string[] }>}
321
+ */
322
+ async syncArtifactBundleFiles({ artifactBundleId, files, message }) {
323
+ // `limit` is capped at 200 by the API; a skills bundle is far smaller.
324
+ const existing = extractBundleFiles(await this.artifacts.list({ artifactBundleId, limit: 200 }));
325
+ const existingByPath = new Map(existing.map(file => [file.path, file]));
326
+
327
+ const created = [];
328
+ const updated = [];
329
+ const unchanged = [];
330
+ const toCreate = [];
331
+ const localPaths = new Set();
332
+
333
+ for (const file of files) {
334
+ localPaths.add(file.path);
335
+ const match = existingByPath.get(file.path);
336
+
337
+ if (!match) {
338
+ toCreate.push(file);
339
+ continue;
340
+ }
341
+
342
+ if (match.contentHash && match.contentHash === contentHashOf(file.content)) {
343
+ unchanged.push(file.path);
344
+ continue;
345
+ }
346
+
347
+ await this.artifacts.update({
348
+ artifactBundleId,
349
+ fileId: match.id,
350
+ content: file.content,
351
+ ...(message ? { message } : {}),
352
+ });
353
+ updated.push(file.path);
354
+ }
355
+
356
+ if (toCreate.length > 0) {
357
+ await this.artifacts.upload({ artifactBundleId, files: toCreate });
358
+ created.push(...toCreate.map(file => file.path));
359
+ }
360
+
361
+ const deleted = [];
362
+ for (const file of existing) {
363
+ if (!localPaths.has(file.path)) {
364
+ await this.artifacts.remove({ artifactBundleId, fileId: file.id });
365
+ deleted.push(file.path);
366
+ }
367
+ }
368
+
369
+ return { created, updated, deleted, unchanged };
370
+ }
371
+
372
+ /**
373
+ * @param {string} id
374
+ * @param {JobWaitOptions} [options]
375
+ */
376
+ async waitForJob(id, { intervalMs = 1000, timeoutMs = 10 * 60 * 1000, signal = undefined, onProgress = undefined } = {}) {
377
+ const startedAt = Date.now();
378
+ let lastPhase = null;
379
+
380
+ while (true) {
381
+ if (signal?.aborted) {
382
+ throw new RubrkitError('Job wait was aborted.', { code: 'aborted' });
383
+ }
384
+
385
+ const data = await this.jobs.get(id);
386
+ const job = data.job ?? data;
387
+
388
+ if (job.phase !== lastPhase || TERMINAL_JOB_STATES.has(job.state)) {
389
+ onProgress?.(job);
390
+ lastPhase = job.phase;
391
+ }
392
+
393
+ if (TERMINAL_JOB_STATES.has(job.state)) {
394
+ return job;
395
+ }
396
+
397
+ if (Date.now() - startedAt > timeoutMs) {
398
+ throw new RubrkitError(`Timed out waiting for Rubrkit job ${id}.`, { code: 'job_wait_timeout', details: { jobId: id } });
399
+ }
400
+
401
+ await sleep(intervalMs);
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * @param {Response} response
408
+ */
409
+ async function parseResponse(response) {
410
+ const text = await response.text();
411
+ const body = text ? parseJson(text) : {};
412
+ const requestId = body && typeof body === 'object' && typeof body.requestId === 'string' ? body.requestId : null;
413
+
414
+ if (!response.ok) {
415
+ const error = body && typeof body === 'object' && 'error' in body ? body.error : {};
416
+ const message =
417
+ error && typeof error === 'object' && typeof error.message === 'string'
418
+ ? error.message
419
+ : `Rubrkit API request failed with HTTP ${response.status}.`;
420
+ const code = error && typeof error === 'object' && typeof error.code === 'string' ? error.code : 'api_request_failed';
421
+
422
+ throw new RubrkitApiError(message, {
423
+ code,
424
+ status: response.status,
425
+ requestId,
426
+ details: error && typeof error === 'object' ? error.details : undefined,
427
+ });
428
+ }
429
+
430
+ return body && typeof body === 'object' && 'data' in body ? body.data : body;
431
+ }
432
+
433
+ /**
434
+ * @param {string} text
435
+ */
436
+ function parseJson(text) {
437
+ try {
438
+ return JSON.parse(text);
439
+ } catch (error) {
440
+ throw new RubrkitApiError('Rubrkit API returned invalid JSON.', {
441
+ code: 'invalid_api_response',
442
+ details: error,
443
+ });
444
+ }
445
+ }
446
+
447
+ /**
448
+ * @param {string} apiUrl
449
+ * @param {string} path
450
+ * @param {Record<string, unknown>} query
451
+ */
452
+ function buildUrl(apiUrl, path, query) {
453
+ const url = new URL(`${apiUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
454
+
455
+ for (const [key, value] of Object.entries(query)) {
456
+ if (value === undefined || value === null || value === false) {
457
+ continue;
458
+ }
459
+
460
+ url.searchParams.set(key, String(value));
461
+ }
462
+
463
+ return url.toString();
464
+ }
465
+
466
+ /**
467
+ * @param {unknown} value
468
+ */
469
+ function encodePath(value) {
470
+ return encodeURIComponent(String(value));
471
+ }
472
+
473
+ /**
474
+ * @param {Record<string, any>} params
475
+ * @param {string} name
476
+ */
477
+ function requireParam(params, name) {
478
+ const value = params[name];
479
+ if (typeof value !== 'string' || !value.trim()) {
480
+ throw new RubrkitError(`Missing required SDK parameter "${name}".`, { code: 'missing_sdk_parameter', details: { name } });
481
+ }
482
+
483
+ return value.trim();
484
+ }
485
+
486
+ /**
487
+ * @param {unknown} value
488
+ * @returns {Record<string, any>}
489
+ */
490
+ function asRecord(value) {
491
+ return value && typeof value === 'object' && !Array.isArray(value) ? /** @type {Record<string, any>} */ (value) : {};
492
+ }
493
+
494
+ /**
495
+ * @param {Record<string, any>} params
496
+ */
497
+ function pickAuditParams(params) {
498
+ const picked = {};
499
+
500
+ for (const key of ['artifact', 'artifactType', 'targetFileId', 'targetVersionNumber', 'artifactBundleVersionNumber', 'rubric', 'rubricKey', 'noAi', 'force']) {
501
+ if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
502
+ picked[key] = params[key];
503
+ }
504
+ }
505
+
506
+ return normalizeAuditBody(picked);
507
+ }
508
+
509
+ /**
510
+ * @param {Record<string, any>} body
511
+ */
512
+ function normalizeAuditBody(body) {
513
+ const normalized = { ...body };
514
+
515
+ if (normalized.rubric !== undefined && normalized.rubricKey === undefined) {
516
+ normalized.rubricKey = normalized.rubric;
517
+ }
518
+
519
+ delete normalized.rubric;
520
+ delete normalized.target;
521
+ delete normalized.ci;
522
+ delete normalized.files;
523
+ delete normalized.name;
524
+ delete normalized.description;
525
+ delete normalized.customRubric;
526
+ delete normalized.settings;
527
+
528
+ return normalized;
529
+ }
530
+
531
+ /**
532
+ * @param {Array<Record<string, any>>} files
533
+ */
534
+ function inferArtifactTestBundleName(files) {
535
+ const firstPath = typeof files[0]?.path === 'string' && files[0].path.trim() ? files[0].path.trim() : 'artifact';
536
+ const suffix = files.length > 1 ? ` and ${files.length - 1} more` : '';
537
+ const name = `CLI test: ${firstPath}${suffix}`;
538
+
539
+ return name.length <= 120 ? name : `${name.slice(0, 117)}...`;
540
+ }
541
+
542
+ /**
543
+ * @param {Record<string, any>} params
544
+ * @param {string[]} keys
545
+ */
546
+ function omit(params, keys) {
547
+ const blocked = new Set(keys);
548
+ return Object.fromEntries(Object.entries(params).filter(([key]) => !blocked.has(key)));
549
+ }
550
+
551
+ /**
552
+ * @param {number} ms
553
+ */
554
+ function sleep(ms) {
555
+ return new Promise(resolve => setTimeout(resolve, ms));
556
+ }