rubrkit 0.3.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/sdk.js +109 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rubrkit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Rubrkit CLI for pulling artifact bundles into local agent projects.",
6
6
  "bin": {
package/src/sdk.js CHANGED
@@ -1,9 +1,35 @@
1
1
  // @ts-check
2
2
 
3
+ import { createHash } from 'node:crypto';
3
4
  import { DEFAULT_API_URL, normalizeApiUrl } from './config.js';
4
5
 
5
6
  const TERMINAL_JOB_STATES = new Set(['succeeded', 'failed', 'cancelled', 'paused']);
6
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
+
7
33
  /**
8
34
  * @typedef {{
9
35
  * apiKey?: string | null,
@@ -112,6 +138,21 @@ export class Rubrkit {
112
138
  body: omit(params, ['artifactBundleId']),
113
139
  });
114
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
+ },
115
156
  test: (params = {}) => this.startArtifactTest(params),
116
157
  };
117
158
 
@@ -222,6 +263,8 @@ export class Rubrkit {
222
263
  let upload = null;
223
264
 
224
265
  if (files.length > 0) {
266
+ const reuseExistingBundle = Boolean(artifactBundleId);
267
+
225
268
  if (!artifactBundleId) {
226
269
  const created = await this.artifactBundles.create({
227
270
  name: params.name ?? inferArtifactTestBundleName(files),
@@ -238,10 +281,13 @@ export class Rubrkit {
238
281
  throw new RubrkitError('Rubrkit API response did not include an artifact bundle ID.', { code: 'missing_artifact_bundle_id' });
239
282
  }
240
283
 
241
- upload = await this.artifacts.upload({
242
- artifactBundleId,
243
- files,
244
- });
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 });
245
291
  }
246
292
 
247
293
  if (!artifactBundleId) {
@@ -264,6 +310,65 @@ export class Rubrkit {
264
310
  };
265
311
  }
266
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
+
267
372
  /**
268
373
  * @param {string} id
269
374
  * @param {JobWaitOptions} [options]