@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 +32 -12
- package/dist/manifest.d.ts +11 -2
- package/dist/manifest.js +129 -17
- package/package.json +1 -1
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 **
|
|
18
|
-
`--base-url`
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
28
|
-
conditional fetch (ETag / `If-None-Match`)
|
|
29
|
-
`~/.cache/
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
408
|
-
*
|
|
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,
|
|
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
|
|
13
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
//
|
|
349
|
-
//
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
|
369
|
-
const cached =
|
|
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
|
-
|
|
525
|
+
replaceCacheAtomically(canonicalCachePath, JSON.stringify({ etag: res.headers.get('etag') ?? undefined, manifest: json }));
|
|
414
526
|
return manifest;
|
|
415
527
|
}
|