@labelbox/recursion-cli 0.0.45 → 0.0.46

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 CHANGED
@@ -14,19 +14,37 @@ rl synthesizers create --help # list flags
14
14
  ## How it works (fully live, zero per-operation code)
15
15
 
16
16
  The CLI ships **no** baked API reference and has **no** `@labelbox/recursion-sdk` dependency.
17
- On each run it **fetches a manifest** from `GET /cli/manifest` on whatever server
18
- `--base-url` points at (production by default, or staging, or `localhost`), validates
19
- it (`src/manifest.ts`), and builds its entire command tree, `--help`, request/response
20
- shapes, and docs browse surfaces from it. Dispatch is generic (`src/dispatch.ts`):
17
+ On each run it **revalidates the manifest** from `GET /cli/manifest` when the server
18
+ selected by `--base-url` is reachable (production by default, or staging, or
19
+ `localhost`). If the server is unavailable, it falls back only to a locally cached
20
+ manifest that was previously validated. From that manifest it builds its entire
21
+ command tree, `--help`, request/response shapes, and docs browse surfaces
22
+ (`src/manifest.ts`). Dispatch is generic (`src/dispatch.ts`):
21
23
  each request is built straight from the manifest operation's HTTP method + path
22
24
  template + params + body — there is no hand-written command per operation and no
23
25
  baked client.
24
26
 
25
27
  The result: **adding or changing a backend endpoint needs zero CLI release** — the
26
28
  live CLI reflects it as soon as the backend deploys. The CLI is re-released only when
27
- its own engine code changes. The manifest is revalidated on every run via a
28
- conditional fetch (ETag / `If-None-Match`), cached per base-url under
29
- `~/.cache/rl-gym/`, so it can never serve stale data and never needs manual busting.
29
+ its own engine code changes. When the server is reachable, the manifest is
30
+ revalidated on every run via a conditional fetch (ETag / `If-None-Match`) and cached
31
+ per base-url under `~/.cache/recursion/`. When the server is unreachable, the CLI may
32
+ use the last locally validated copy so commands remain available offline. If only the
33
+ previous `~/.cache/rl-gym/` entry exists, the CLI validates and uses it immediately,
34
+ then adopts it into the Recursion cache non-destructively. The
35
+ old entry is retained unchanged for rollback; new server responses are written only
36
+ to the Recursion cache.
37
+
38
+ Legacy adoption uses atomic create-if-absent publication: a validated copy is
39
+ hard-linked from a unique same-directory temporary file, so it never replaces a
40
+ canonical entry created concurrently. A valid concurrent canonical entry wins; an
41
+ invalid one is left untouched while that invocation uses the validated legacy value
42
+ in memory. Validated fresh HTTP `200` responses use atomic same-directory rename and
43
+ therefore retain last-network-writer behavior. Replacement preserves an existing
44
+ file's POSIX mode bits, but deliberately publishes a new inode and does not preserve
45
+ its ACLs or extended attributes. Both paths require parent-directory write/search
46
+ permission; if unavailable, the CLI keeps using the validated in-memory result and
47
+ never falls back to a partial direct write.
30
48
 
31
49
  ### Docs browse surfaces
32
50
 
@@ -100,11 +118,13 @@ rl synthesizer-runs trigger --problem-version-id pv_01HX... --from-json ./run.js
100
118
  ## The command surface is the live server
101
119
 
102
120
  There is **nothing to regenerate or commit** for the CLI — the command surface is
103
- fetched fresh from the target server's `GET /cli/manifest` on every run, so a backend
104
- change flows through automatically after deploy with no CLI step. The manifest itself
105
- is assembled by `yarn generate cli:manifest` (included in `yarn generate prerequisites`) from the
106
- spec-derived reference files and embedded into the backend; to add or change a
107
- command, change the backend `@SdkRoute` not this package.
121
+ revalidated against the target server's `GET /cli/manifest` on every run when the
122
+ server is reachable, with a validated local-cache fallback when it is offline. A
123
+ backend change therefore flows through automatically after deploy with no CLI step.
124
+ The manifest itself is assembled by `yarn generate cli:manifest` (included in
125
+ `yarn generate prerequisites`) from the spec-derived reference files and embedded
126
+ into the backend; to add or change a command, change the backend `@SdkRoute` — not
127
+ this package.
108
128
 
109
129
  The *engine* (manifest fetch + cache + validation, generic dispatch, flag mapping,
110
130
  help formatting, the request-body / returns shape trees, and the docs browse
@@ -398,13 +398,22 @@ export type Manifest = z.infer<typeof ManifestSchema>;
398
398
  * and only a deliberate format bump trips this.
399
399
  */
400
400
  export declare function parseManifest(value: unknown, source: string): Manifest;
401
+ /**
402
+ * A filesystem-safe, collision-free slug for a base URL, so each server caches
403
+ * independently. The readable part is the sanitized URL (handy when eyeballing the
404
+ * cache dir); a short hash of the *full* URL is appended so two URLs that sanitize
405
+ * to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
406
+ * distinct cache files rather than silently sharing — and poisoning — one.
407
+ */
408
+ export declare function hostSlug(baseUrl: string): string;
401
409
  /**
402
410
  * Fetch the command manifest, revalidating the cache on every run:
403
411
  * - send `If-None-Match` with the cached ETag → `304` means the cache is provably
404
412
  * current (use it); `200` means the surface changed (validate + replace cache).
405
413
  * - a network error (incl. a timeout) falls back to the cached copy with a warning;
406
414
  * with no cache it errors clearly.
407
- * The cache therefore can never serve stale data and never needs manual busting — a
408
- * backend redeploy is picked up automatically on the next command.
415
+ * When the server is reachable, the cache is revalidated and a backend redeploy is
416
+ * picked up automatically. Offline, the last locally validated copy may be older
417
+ * than the server but keeps the CLI usable.
409
418
  */
410
419
  export declare function fetchManifest(baseUrl: string, apiKey: string): Promise<Manifest>;
package/dist/manifest.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createHash } from 'node:crypto';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, fchmodSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import process from 'node:process';
@@ -9,8 +9,8 @@ import { z } from 'zod';
9
9
  // from `GET /cli/manifest` on whatever server `--base-url` points at, instead of
10
10
  // baking a compiled-in `@labelbox/recursion-sdk` reference. This module owns:
11
11
  // 1. the manifest's runtime Zod schema + TS types (the validation boundary), and
12
- // 2. `fetchManifest` — a conditional-fetch cache (ETag / If-None-Match) that can
13
- // never serve stale data and needs no manual busting.
12
+ // 2. `fetchManifest` — a conditional-fetch cache (ETag / If-None-Match) that is
13
+ // revalidated whenever the server is reachable and remains usable offline.
14
14
  // The schema lives here, not in sdk-ts: the CLI no longer depends on that package
15
15
  // (see the plan's standalone-CLI decision). The backend embeds the same manifest at
16
16
  // build time (`dx manifest:generate`); this is the consumer mirror.
@@ -300,12 +300,15 @@ const CacheSchema = z.object({ etag: z.string().optional(), manifest: z.unknown(
300
300
  * to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
301
301
  * distinct cache files rather than silently sharing — and poisoning — one.
302
302
  */
303
- function hostSlug(baseUrl) {
303
+ export function hostSlug(baseUrl) {
304
304
  const readable = baseUrl.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') || 'default';
305
305
  const hash = createHash('sha256').update(baseUrl, 'utf8').digest('hex').slice(0, 8);
306
306
  return `${readable}-${hash}`;
307
307
  }
308
- function cachePathFor(baseUrl) {
308
+ function canonicalCachePathFor(baseUrl) {
309
+ return join(homedir(), '.cache', 'recursion', `${hostSlug(baseUrl)}.json`);
310
+ }
311
+ function legacyCachePathFor(baseUrl) {
309
312
  return join(homedir(), '.cache', 'rl-gym', `${hostSlug(baseUrl)}.json`);
310
313
  }
311
314
  function readCache(path) {
@@ -333,21 +336,129 @@ function readCache(path) {
333
336
  // arrives). Treating it as cold lets the next fetch pull — and re-cache — a full
334
337
  // body, so the CLI self-heals without a manual cache delete.
335
338
  try {
336
- return { etag: parsed.data.etag, manifest: parseManifest(parsed.data.manifest, 'the cache') };
339
+ return {
340
+ etag: parsed.data.etag,
341
+ manifest: parseManifest(parsed.data.manifest, 'the cache'),
342
+ raw,
343
+ };
337
344
  }
338
345
  catch {
339
346
  return undefined;
340
347
  }
341
348
  }
342
- function writeCache(path, entry) {
349
+ const CACHE_TEMP_PREFIX = '.rlc-';
350
+ function uniqueCacheTempPath(directory) {
351
+ return join(directory, `${CACHE_TEMP_PREFIX}${process.pid.toString()}-${randomUUID()}.tmp`);
352
+ }
353
+ /**
354
+ * Create, populate, close, and publish one exclusively owned same-directory temp.
355
+ *
356
+ * Ownership begins immediately after `openSync` succeeds, so every later failure —
357
+ * including a partial write or close failure — triggers best-effort pathname cleanup.
358
+ * Descriptor ownership is cleared before the single close attempt: retrying a numeric
359
+ * descriptor could close an unrelated resource if the operating system reused it.
360
+ * Publication happens only after close succeeds. The caller reports whether it
361
+ * transferred the inode (rename) or retained it (hard-link adoption), which determines
362
+ * final cleanup ownership.
363
+ */
364
+ function publishFromOwnedTemp(path, raw, prepare, publish) {
365
+ let tempPath;
366
+ let descriptor;
367
+ let ownsTemp = false;
343
368
  try {
344
- mkdirSync(dirname(path), { recursive: true });
345
- writeFileSync(path, JSON.stringify(entry));
369
+ const directory = dirname(path);
370
+ mkdirSync(directory, { recursive: true });
371
+ tempPath = uniqueCacheTempPath(directory);
372
+ descriptor = openSync(tempPath, 'wx');
373
+ ownsTemp = true;
374
+ const bytes = Buffer.from(raw, 'utf8');
375
+ let offset = 0;
376
+ while (offset < bytes.byteLength) {
377
+ const written = writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
378
+ if (written === 0)
379
+ throw new Error('writing the cache temp made no progress');
380
+ offset += written;
381
+ }
382
+ prepare(descriptor);
383
+ const descriptorToClose = descriptor;
384
+ descriptor = undefined;
385
+ closeSync(descriptorToClose);
386
+ if (publish(tempPath) === 'transferred')
387
+ ownsTemp = false;
346
388
  }
347
389
  catch {
348
- // A non-writable cache dir is a bandwidth optimization lost, not a failure
349
- // the command still ran off the fetched manifest. Stay silent.
390
+ // Cache persistence is a bandwidth optimization. The caller already has a
391
+ // validated in-memory manifest, so publication failures are non-fatal.
350
392
  }
393
+ finally {
394
+ if (descriptor !== undefined) {
395
+ const descriptorToClose = descriptor;
396
+ descriptor = undefined;
397
+ try {
398
+ closeSync(descriptorToClose);
399
+ }
400
+ catch {
401
+ // Never retry a numeric descriptor: it may already have been reused.
402
+ }
403
+ }
404
+ if (ownsTemp && tempPath !== undefined) {
405
+ try {
406
+ unlinkSync(tempPath);
407
+ }
408
+ catch {
409
+ // Pathname cleanup is best-effort; readers inspect only the final path.
410
+ }
411
+ }
412
+ }
413
+ }
414
+ /**
415
+ * Replace a cache with a validated HTTP 200 response via same-directory rename.
416
+ *
417
+ * Rename deliberately publishes a new inode: an existing destination's POSIX mode
418
+ * bits are copied to that inode, but its ACLs and xattrs are not. The operation
419
+ * requires write/search permission on the parent directory; it never falls back to
420
+ * a partial direct write when temporary-file creation or rename is unavailable.
421
+ */
422
+ function replaceCacheAtomically(path, raw) {
423
+ publishFromOwnedTemp(path, raw, (descriptor) => {
424
+ // Read the mode immediately before publication. Concurrent HTTP 200 writers all
425
+ // preserve the established mode while rename keeps last-network-writer-wins.
426
+ const destination = statSync(path, { throwIfNoEntry: false });
427
+ if (destination !== undefined)
428
+ fchmodSync(descriptor, destination.mode & 0o7777);
429
+ }, (tempPath) => {
430
+ renameSync(tempPath, path);
431
+ return 'transferred';
432
+ });
433
+ }
434
+ /**
435
+ * Publish validated legacy bytes only if the canonical destination is absent.
436
+ *
437
+ * The hard link is the no-replace commit point: unlike rename, it fails if any
438
+ * concurrent invocation has created the destination. Best-effort temporary-path
439
+ * cleanup is attempted afterward, while a winning canonical path is never overwritten.
440
+ */
441
+ function publishCacheIfAbsent(path, raw) {
442
+ publishFromOwnedTemp(path, raw, () => { }, (tempPath) => {
443
+ linkSync(tempPath, path);
444
+ return 'retained';
445
+ });
446
+ }
447
+ /** Prefer canonical cache; otherwise adopt a validated legacy file without mutating it. */
448
+ function selectCache(baseUrl) {
449
+ const canonicalPath = canonicalCachePathFor(baseUrl);
450
+ const canonical = readCache(canonicalPath);
451
+ if (canonical !== undefined)
452
+ return canonical;
453
+ const legacy = readCache(legacyCachePathFor(baseUrl));
454
+ if (legacy === undefined)
455
+ return undefined;
456
+ // Copy the validated bytes exactly. Hard-link publication cannot replace a
457
+ // canonical file that appears after the reads above. Re-read afterward: prefer a
458
+ // valid concurrent winner, but leave an invalid winner untouched and keep using
459
+ // the already-validated legacy value in memory.
460
+ publishCacheIfAbsent(canonicalPath, legacy.raw);
461
+ return readCache(canonicalPath) ?? legacy;
351
462
  }
352
463
  // Every `rl` invocation gates on this fetch, so it must be bounded: a server that
353
464
  // accepts the connection but never responds (a hung gateway, a stalled captive
@@ -360,13 +471,14 @@ const MANIFEST_FETCH_TIMEOUT_MS = 30_000;
360
471
  * current (use it); `200` means the surface changed (validate + replace cache).
361
472
  * - a network error (incl. a timeout) falls back to the cached copy with a warning;
362
473
  * with no cache it errors clearly.
363
- * The cache therefore can never serve stale data and never needs manual busting — a
364
- * backend redeploy is picked up automatically on the next command.
474
+ * When the server is reachable, the cache is revalidated and a backend redeploy is
475
+ * picked up automatically. Offline, the last locally validated copy may be older
476
+ * than the server but keeps the CLI usable.
365
477
  */
366
478
  export async function fetchManifest(baseUrl, apiKey) {
367
479
  const url = supportUrl(baseUrl, '/cli/manifest');
368
- const cachePath = cachePathFor(baseUrl);
369
- const cached = readCache(cachePath);
480
+ const canonicalCachePath = canonicalCachePathFor(baseUrl);
481
+ const cached = selectCache(baseUrl);
370
482
  let res;
371
483
  try {
372
484
  res = await fetch(url, {
@@ -410,6 +522,6 @@ export async function fetchManifest(baseUrl, apiKey) {
410
522
  'points at a recursion API (not a login page or proxy).');
411
523
  }
412
524
  const manifest = parseManifest(json, url);
413
- writeCache(cachePath, { etag: res.headers.get('etag') ?? undefined, manifest: json });
525
+ replaceCacheAtomically(canonicalCachePath, JSON.stringify({ etag: res.headers.get('etag') ?? undefined, manifest: json }));
414
526
  return manifest;
415
527
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labelbox/recursion-cli",
3
- "version": "0.0.45",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",