rubrkit 0.1.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 ADDED
@@ -0,0 +1,443 @@
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
+ }