@pipelex/sdk 0.49.0 → 0.51.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/README.md +32 -12
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +347 -15
- package/dist/client.js +613 -31
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +15 -7
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/product-models.d.ts +590 -20
- package/dist/upload-grant.d.ts +134 -40
- package/dist/upload-grant.js +328 -136
- package/dist/upload-grant.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +6 -5
- package/docs/errors.md +12 -4
- package/docs/files.md +173 -3
- package/docs/input-preparation.md +43 -19
- package/docs/method-copy.md +126 -0
- package/docs/method-examples.md +36 -3
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -216,9 +216,28 @@ const EXAMPLE_ID_ARGUMENT = "an example id, the example's non-empty `example_id`
|
|
|
216
216
|
const RUN_ID_ARGUMENT = "a run id, the run's non-empty `pipeline_run_id`";
|
|
217
217
|
const ORG_ID_ARGUMENT = "an organization id, the organization's non-empty `org_id`";
|
|
218
218
|
const API_KEY_ID_ARGUMENT = "an API key id, the key's non-empty `id`";
|
|
219
|
+
const FILE_ID_ARGUMENT = "a file id, the file's non-empty `file_id`";
|
|
220
|
+
const ARCHIVE_ID_ARGUMENT = "an archive id, the archive's non-empty `archive_id`";
|
|
219
221
|
/** What the run id of each example write is, as a refusal of it names it. */
|
|
220
222
|
const CREATE_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used these inputs, a non-empty string, or none";
|
|
221
223
|
const RECORD_RUN_ID_ARGUMENT = "a run_id naming a run of this method that used the example's inputs, a non-empty string";
|
|
224
|
+
/** What the example an assistant save keeps current is, as a refusal of it names it. */
|
|
225
|
+
const ASSISTANT_EXAMPLE_ID_ARGUMENT = "an example_id naming the assistant's example the caller read, a non-empty string, or none";
|
|
226
|
+
const ASSISTANT_REVISION_ARGUMENT = "an input_revision, the revision the caller read the assistant's example at, a positive integer";
|
|
227
|
+
/** What each member of the files routes' bodies is, as a refusal of it names it. */
|
|
228
|
+
const STORAGE_URI_ARGUMENT = "a `pipelex-storage://` address, a non-empty string";
|
|
229
|
+
const RENAME_NAME_ARGUMENT = "the file's new name, a string";
|
|
230
|
+
const LINK_TARGET_ARGUMENT = "a file to link, an object naming exactly one of `file_id` and `uri`, each a non-empty string";
|
|
231
|
+
const ARCHIVE_FILE_ID_ARGUMENT = "a file id to zip, a non-empty string";
|
|
232
|
+
const ARCHIVE_RUN_ID_ARGUMENT = "a run_id naming the run whose files to zip, a non-empty string";
|
|
233
|
+
const ARCHIVE_NAME_ARGUMENT = "a name for the zip, a string, or none";
|
|
234
|
+
const DRAFT_PATH_ARGUMENT = "the tree path the file takes in the draft, a non-empty string";
|
|
235
|
+
const DRAFT_TOKEN_ARGUMENT = "the draft token the caller last read, the method's `updated_at`, a string, or none";
|
|
236
|
+
/** What each argument of a method copy is, as a refusal of it names it. */
|
|
237
|
+
const COPY_DESTINATION_ARGUMENT = "a destination_org_id, the canonical id of the organization to copy into, a non-empty string";
|
|
238
|
+
const COPY_NAME_ARGUMENT = "a name for the copy, a string, or none";
|
|
239
|
+
const COPY_DRAFT_TOKEN_ARGUMENT = "an expected_draft_updated_at, the draft token the caller last read, the method's " +
|
|
240
|
+
"`updated_at`, a string, or none";
|
|
222
241
|
/**
|
|
223
242
|
* The refusal of an argument a call cannot take, worded as every such refusal is:
|
|
224
243
|
* `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
|
|
@@ -243,6 +262,22 @@ function requireNonEmptyId(value, call, what) {
|
|
|
243
262
|
got = value === null ? "null" : typeof value;
|
|
244
263
|
throw argumentRefusal(call, what, got);
|
|
245
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* The check the example writes that take a name and inputs share, the create and the assistant's
|
|
267
|
+
* save: a `name` or `inputs` not given, a `null` counting as not given, is refused with
|
|
268
|
+
* `RequestArgumentError` before anything is sent, `call` naming the method in the message and
|
|
269
|
+
* `needs` what it needs.
|
|
270
|
+
*/
|
|
271
|
+
function requireNameAndInputs(name, inputs, call, needs) {
|
|
272
|
+
if (name != null && inputs != null)
|
|
273
|
+
return;
|
|
274
|
+
let got;
|
|
275
|
+
if (name == null && inputs == null)
|
|
276
|
+
got = "neither";
|
|
277
|
+
else
|
|
278
|
+
got = name == null ? "no name" : "no inputs";
|
|
279
|
+
throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`);
|
|
280
|
+
}
|
|
246
281
|
/**
|
|
247
282
|
* A caller's value as the refusal of a number argument names it: a number by its value, anything
|
|
248
283
|
* else by its type, as `requireNonEmptyId` names a value, and `null` as itself. Never by `String`,
|
|
@@ -307,6 +342,37 @@ function methodExamplePath(methodId, exampleId, call) {
|
|
|
307
342
|
return `${path}/examples/${idPathSegment(exampleId, call, EXAMPLE_ID_ARGUMENT)}`;
|
|
308
343
|
}
|
|
309
344
|
const isString = (value) => typeof value === "string";
|
|
345
|
+
/** The states an archive is in, the field a caller branches on, in the order a refusal lists them. */
|
|
346
|
+
const FILE_ARCHIVE_STATES = ["queued", "building", "ready", "failed"];
|
|
347
|
+
/** What a copy into a draft did in the blob store, the field its answer is discriminated on. */
|
|
348
|
+
const DRAFT_FILE_BLOBS = ["copied", "already_held"];
|
|
349
|
+
/** What a method copy did, the field its answer is discriminated on. */
|
|
350
|
+
const METHOD_COPY_OUTCOMES = ["copied", "refused"];
|
|
351
|
+
/** Whether a run's records are all written, the field a reader of its files branches on. */
|
|
352
|
+
const RUN_FILES_STATES = ["listing", "listed"];
|
|
353
|
+
/** The lists of a page of a run's files, each an array on every page. */
|
|
354
|
+
const RUN_FILES_LISTS = ["inputs", "outputs", "intermediates", "links"];
|
|
355
|
+
/**
|
|
356
|
+
* One file `linkFiles` is asked to link, checked and reduced to what the closed body takes:
|
|
357
|
+
* `{ file_id }` or `{ uri }`, exactly one of them a non-empty string. Anything else, an object
|
|
358
|
+
* naming both, neither or an empty one included, is refused with `RequestArgumentError` before
|
|
359
|
+
* anything is sent, as the platform would refuse the whole request with a `422`.
|
|
360
|
+
*/
|
|
361
|
+
function fileLinkTarget(value) {
|
|
362
|
+
if (!isPlainObject(value)) {
|
|
363
|
+
throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value));
|
|
364
|
+
}
|
|
365
|
+
const fileId = value.file_id ?? undefined;
|
|
366
|
+
const uri = value.uri ?? undefined;
|
|
367
|
+
if ((fileId === undefined) === (uri === undefined)) {
|
|
368
|
+
const got = fileId === undefined ? "an object naming neither" : "an object naming both";
|
|
369
|
+
throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got);
|
|
370
|
+
}
|
|
371
|
+
if (fileId !== undefined) {
|
|
372
|
+
return { file_id: requireNonEmptyId(fileId, "linkFiles", LINK_TARGET_ARGUMENT) };
|
|
373
|
+
}
|
|
374
|
+
return { uri: requireNonEmptyId(uri, "linkFiles", LINK_TARGET_ARGUMENT) };
|
|
375
|
+
}
|
|
310
376
|
/**
|
|
311
377
|
* Each field of a `MethodExample`, what it must be, and the check it must pass: every field the
|
|
312
378
|
* Python SDK's model requires, held to the same strict types it holds them to, so both SDKs refuse
|
|
@@ -331,6 +397,7 @@ const METHOD_EXAMPLE_FIELDS = {
|
|
|
331
397
|
created_by_user_id: ["a string", isString],
|
|
332
398
|
created_at: ["a string", isString],
|
|
333
399
|
updated_at: ["a string", isString],
|
|
400
|
+
kept_by_assistant: ["a boolean", (value) => typeof value === "boolean"],
|
|
334
401
|
};
|
|
335
402
|
export class PipelexApiClient {
|
|
336
403
|
apiKey;
|
|
@@ -419,7 +486,7 @@ export class PipelexApiClient {
|
|
|
419
486
|
* resolved absolute URL. `mayStartRun` marks a request that may create a run,
|
|
420
487
|
* whose `ApiUnreachableError` then says whether it may have (see `RunCreation`).
|
|
421
488
|
* `idempotencyKey`, already checked by `idempotencyKeyOf`, is sent as the
|
|
422
|
-
* `Idempotency-Key` header; only `start`
|
|
489
|
+
* `Idempotency-Key` header; only `start` and `copyMethod` pass one.
|
|
423
490
|
*/
|
|
424
491
|
async requestRaw(method, url, options = {}) {
|
|
425
492
|
const hasBody = options.body !== undefined;
|
|
@@ -632,6 +699,7 @@ export class PipelexApiClient {
|
|
|
632
699
|
body,
|
|
633
700
|
timeoutMs: options.timeoutMs ?? POLL_REQUEST_TIMEOUT_MS,
|
|
634
701
|
signal: options.signal,
|
|
702
|
+
idempotencyKey: options.idempotencyKey,
|
|
635
703
|
});
|
|
636
704
|
if (res.status < 200 || res.status >= 300) {
|
|
637
705
|
this.throwApiResponseError(method, endpoint, res);
|
|
@@ -2287,6 +2355,116 @@ export class PipelexApiClient {
|
|
|
2287
2355
|
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
2288
2356
|
return this.readSavedTree("GET", endpoint, withLinks(res), "a published version");
|
|
2289
2357
|
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Copy a method into an organization the caller may change methods in —
|
|
2360
|
+
* `POST /v1/methods/{id}/copy`: the method's own organization for a duplicate, another team, or
|
|
2361
|
+
* the caller's personal workspace.
|
|
2362
|
+
*
|
|
2363
|
+
* The copy is a new, unpublished method of the destination, the caller's, holding the
|
|
2364
|
+
* original's draft tree (the same `draft_digest`), its name unless `name` gives another, its
|
|
2365
|
+
* form inputs and every saved example, each stored file they name copied into the destination,
|
|
2366
|
+
* and `copied_from` naming the original. Published versions and runs are not copied, and the
|
|
2367
|
+
* original stays as it was. The copy takes the draft, never the latest published version; with
|
|
2368
|
+
* `expected_draft_updated_at`, it takes exactly the draft the caller last saw.
|
|
2369
|
+
*
|
|
2370
|
+
* The answer is a `MethodCopyResult` discriminated on `outcome` — branch on it: `copied` with
|
|
2371
|
+
* the copy as `method`, `examples_copied` and `stored_files_copied`; `refused` with the
|
|
2372
|
+
* `reason` `bundle_names_other_storage`, a `message` and every `references` entry (`{ path, uri
|
|
2373
|
+
* }`) a `.mthds` file of the draft holds that the destination cannot read, nothing written.
|
|
2374
|
+
*
|
|
2375
|
+
* The route serves web sessions only: a Pipelex API key belongs to one organization and cannot
|
|
2376
|
+
* speak for another. `options.idempotencyKey` is sent as the `Idempotency-Key` header, so a copy
|
|
2377
|
+
* sent again under the same key is answered with the first one's answer rather than copied
|
|
2378
|
+
* twice (see `MethodCopyRequestOptions`).
|
|
2379
|
+
*
|
|
2380
|
+
* Takes a bare catalog id, as `getMethod` does. Sends `destination_org_id` and, when given,
|
|
2381
|
+
* `name` and `expected_draft_updated_at`, and nothing else: the body is closed, and a `null`
|
|
2382
|
+
* reads as not given. Throws `RequestArgumentError` before any request for a
|
|
2383
|
+
* `destination_org_id` that is not a non-empty string, a `name` or an
|
|
2384
|
+
* `expected_draft_updated_at` given that is not a string, and a malformed idempotency key.
|
|
2385
|
+
* Throws `ApiResponseError` when no verdict was produced, each refusal writing nothing a reader
|
|
2386
|
+
* sees: `403` `forbidden` for a request that is not from a web session; `403`
|
|
2387
|
+
* `role_not_permitted` for a role that may not change methods, in the method's organization or
|
|
2388
|
+
* in the destination; `404` `organization_not_found` for a destination the caller does not
|
|
2389
|
+
* belong to, still being set up or unknown, alike; `404` `not_found` for an unknown method;
|
|
2390
|
+
* `409` `method_being_deleted`; `409` `method_update_conflict` for a draft that moved since
|
|
2391
|
+
* `expected_draft_updated_at`; `409` `conflict`, `runtime`, for a draft whose tree the
|
|
2392
|
+
* organization holds no full record of; `413` `payload_too_large` for an example or a copy too
|
|
2393
|
+
* large for one stored item; `422` `validation_failed` for a body that does not validate, such
|
|
2394
|
+
* as a name holding a lone surrogate; `422` `method_copy_too_large` past one of the copy's
|
|
2395
|
+
* bounds on the stored files it copies, named by `problemDocument.limit` (`MethodCopyLimit`);
|
|
2396
|
+
* and, each retryable, a `502` when the platform could not read the caller's memberships, a
|
|
2397
|
+
* `500` `storage_backend_failed` and a `503` past the copy's deadline. An answer whose
|
|
2398
|
+
* `outcome` is neither arm's, a `copied` one whose `method` is not an object or a `refused` one
|
|
2399
|
+
* whose `references` is not an array, is an answer the SDK cannot read, thrown as an
|
|
2400
|
+
* `ApiResponseError` too.
|
|
2401
|
+
*/
|
|
2402
|
+
async copyMethod(methodId, input, options = {}) {
|
|
2403
|
+
const call = "copyMethod";
|
|
2404
|
+
const endpoint = `${methodPath(methodId, call)}/copy`;
|
|
2405
|
+
const { destination_org_id: destinationOrgId, name, expected_draft_updated_at: token, } = input ?? {};
|
|
2406
|
+
const body = {
|
|
2407
|
+
destination_org_id: requireNonEmptyId(destinationOrgId, call, COPY_DESTINATION_ARGUMENT),
|
|
2408
|
+
};
|
|
2409
|
+
if (name != null) {
|
|
2410
|
+
if (typeof name !== "string") {
|
|
2411
|
+
throw argumentRefusal(call, COPY_NAME_ARGUMENT, describeNumberArgument(name));
|
|
2412
|
+
}
|
|
2413
|
+
body.name = name;
|
|
2414
|
+
}
|
|
2415
|
+
if (token != null) {
|
|
2416
|
+
if (typeof token !== "string") {
|
|
2417
|
+
throw argumentRefusal(call, COPY_DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
|
|
2418
|
+
}
|
|
2419
|
+
body.expected_draft_updated_at = token;
|
|
2420
|
+
}
|
|
2421
|
+
const idempotencyKey = idempotencyKeyOf(options, "copy the method");
|
|
2422
|
+
const res = await this.requestProductAnswer("POST", endpoint, body, { idempotencyKey });
|
|
2423
|
+
const answer = this.readObjectAnswer("POST", endpoint, res);
|
|
2424
|
+
let fault;
|
|
2425
|
+
switch (answer.outcome) {
|
|
2426
|
+
case "copied":
|
|
2427
|
+
if (!isPlainObject(answer.method)) {
|
|
2428
|
+
fault = "a `copied` copy result whose `method` is not an object";
|
|
2429
|
+
}
|
|
2430
|
+
break;
|
|
2431
|
+
case "refused":
|
|
2432
|
+
if (!Array.isArray(answer.references)) {
|
|
2433
|
+
fault = "a `refused` copy result whose `references` is not an array";
|
|
2434
|
+
}
|
|
2435
|
+
break;
|
|
2436
|
+
default:
|
|
2437
|
+
fault = `a copy result whose \`outcome\` is not ${inWords(METHOD_COPY_OUTCOMES.map((outcome) => `\`${outcome}\``), "or")}`;
|
|
2438
|
+
}
|
|
2439
|
+
if (fault !== undefined) {
|
|
2440
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, fault);
|
|
2441
|
+
}
|
|
2442
|
+
return answer;
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* One page of the copies made of a method, in the order of the copies' ids, which says nothing
|
|
2446
|
+
* of when each was made — `GET /v1/methods/{id}/copies`.
|
|
2447
|
+
*
|
|
2448
|
+
* Each row is the `MethodCopyMarker` the original keeps of a copy: the copy's id, the
|
|
2449
|
+
* destination's id, the member who made it and when. The destination is named by its id alone,
|
|
2450
|
+
* never its name, so a surface shows a destination's name only from the reader's own
|
|
2451
|
+
* memberships, and a copy in an organization the reader does not belong to stays a bare id. A
|
|
2452
|
+
* marker stays when its copy is erased, and the copy then reads as not found. Pass the page's
|
|
2453
|
+
* `nextCursor` back as `cursor` to continue; `null` is the last page. A method never copied,
|
|
2454
|
+
* and a copy itself, answer an empty page. The listing is open to every member of the method's
|
|
2455
|
+
* organization, a runner included, as every read of a method is.
|
|
2456
|
+
*
|
|
2457
|
+
* Takes a bare catalog id, as `getMethod` does. Throws `ApiResponseError`: `404` `not_found` for
|
|
2458
|
+
* an unknown method, `409` `method_being_deleted`, `400` `malformed_request` for a cursor the
|
|
2459
|
+
* listing did not issue for this method, `422` for a `limit` outside 1 to 100. A page whose
|
|
2460
|
+
* `items` is not an array or whose `next_cursor` is neither a string nor `null` is an answer the
|
|
2461
|
+
* SDK cannot read, thrown as an `ApiResponseError` too.
|
|
2462
|
+
*/
|
|
2463
|
+
async listMethodCopies(methodId, query = {}) {
|
|
2464
|
+
const search = productQuery({ limit: query.limit, cursor: query.cursor });
|
|
2465
|
+
const endpoint = `${methodPath(methodId, "listMethodCopies")}/copies`;
|
|
2466
|
+
return this.requestPage(`${endpoint}${search}`);
|
|
2467
|
+
}
|
|
2290
2468
|
/**
|
|
2291
2469
|
* Erase a method and everything it produced — `DELETE /v1/methods/{id}`.
|
|
2292
2470
|
*
|
|
@@ -2337,14 +2515,7 @@ export class PipelexApiClient {
|
|
|
2337
2515
|
async createMethodExample(methodId, input) {
|
|
2338
2516
|
const endpoint = `${methodPath(methodId, "createMethodExample")}/examples`;
|
|
2339
2517
|
const { name, inputs, run_id: runId, } = input ?? {};
|
|
2340
|
-
|
|
2341
|
-
let got;
|
|
2342
|
-
if (name == null && inputs == null)
|
|
2343
|
-
got = "neither";
|
|
2344
|
-
else
|
|
2345
|
-
got = name == null ? "no name" : "no inputs";
|
|
2346
|
-
throw new RequestArgumentError(`createMethodExample() needs the example's name and its inputs; got ${got}.`);
|
|
2347
|
-
}
|
|
2518
|
+
requireNameAndInputs(name, inputs, "createMethodExample", "the example's name and its inputs");
|
|
2348
2519
|
const body = { name, inputs };
|
|
2349
2520
|
if (runId != null) {
|
|
2350
2521
|
body.run_id = requireNonEmptyId(runId, "createMethodExample", CREATE_RUN_ID_ARGUMENT);
|
|
@@ -2492,6 +2663,64 @@ export class PipelexApiClient {
|
|
|
2492
2663
|
});
|
|
2493
2664
|
return { ...answer, outcome, example: subject };
|
|
2494
2665
|
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Save the build assistant's test inputs as its example of the method —
|
|
2668
|
+
* `POST /v1/methods/{id}/examples/assistant-save` — **never writing over a person's edit**.
|
|
2669
|
+
*
|
|
2670
|
+
* The assistant keeps one example of a method current with its test inputs, until a person
|
|
2671
|
+
* edits that example's inputs. Find its example as the method's newest with `kept_by_assistant`
|
|
2672
|
+
* true (`listMethodExamples` answers them oldest first), and send its `example_id` with the
|
|
2673
|
+
* `input_revision` you read it at. The platform writes the inputs into it only while it still
|
|
2674
|
+
* exists, still carries the flag and still holds that revision, in one conditional write that it
|
|
2675
|
+
* never retries. Otherwise, and when no example is named, which is how the first save on a
|
|
2676
|
+
* method creates the assistant's example, it saves the inputs as a new example flagged as the
|
|
2677
|
+
* assistant's, under `name`.
|
|
2678
|
+
*
|
|
2679
|
+
* The answer is a `MethodExampleAssistantSaveResult` discriminated on `outcome` — branch on it:
|
|
2680
|
+
* `kept`, the named example now holds the inputs, its name untouched; `created`, a new example
|
|
2681
|
+
* holds them. Both carry the `example`, which is the assistant's from then on. A retry of a save
|
|
2682
|
+
* that landed answers `kept` and writes nothing again.
|
|
2683
|
+
*
|
|
2684
|
+
* Sends `name`, `inputs` and, when given, `example_id` with `input_revision`, and nothing else:
|
|
2685
|
+
* the body is closed, and `null` reads as not given. Throws `RequestArgumentError` before any
|
|
2686
|
+
* request when `name` or `inputs` is not given, for an `example_id` that is not a non-empty
|
|
2687
|
+
* string or an `input_revision` that is not a positive integer, and when only one of the two is
|
|
2688
|
+
* given. Throws `ApiResponseError`: `403` `role_not_permitted` for a role that may not change
|
|
2689
|
+
* methods, such as a runner; `413` `payload_too_large` for an example larger than one stored item
|
|
2690
|
+
* holds; `422` for a name that is empty, blank or over 200 characters, or inputs that are not a
|
|
2691
|
+
* JSON object; and the codes every example route answers. An answer whose `outcome` is neither
|
|
2692
|
+
* arm's, or whose `example` is not an example `readMethodExample` can read, is an answer the SDK
|
|
2693
|
+
* cannot read, thrown as an `ApiResponseError` too.
|
|
2694
|
+
*/
|
|
2695
|
+
async saveAssistantMethodExample(methodId, input) {
|
|
2696
|
+
const call = "saveAssistantMethodExample";
|
|
2697
|
+
const endpoint = `${methodPath(methodId, call)}/examples/assistant-save`;
|
|
2698
|
+
const { name, inputs, example_id: exampleId, input_revision: revision, } = input ?? {};
|
|
2699
|
+
requireNameAndInputs(name, inputs, call, "the name a new example takes and the inputs");
|
|
2700
|
+
const body = { name, inputs };
|
|
2701
|
+
if (exampleId != null) {
|
|
2702
|
+
body.example_id = requireNonEmptyId(exampleId, call, ASSISTANT_EXAMPLE_ID_ARGUMENT);
|
|
2703
|
+
}
|
|
2704
|
+
if (revision != null) {
|
|
2705
|
+
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
|
|
2706
|
+
throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision));
|
|
2707
|
+
}
|
|
2708
|
+
body.input_revision = revision;
|
|
2709
|
+
}
|
|
2710
|
+
if ((body.example_id === undefined) !== (body.input_revision === undefined)) {
|
|
2711
|
+
throw new RequestArgumentError(`${call}() names the assistant's example by both its example_id and the input_revision ` +
|
|
2712
|
+
`it was read at, or neither; got only ${body.example_id === undefined ? "input_revision" : "example_id"}.`);
|
|
2713
|
+
}
|
|
2714
|
+
const res = await this.requestProductAnswer("POST", endpoint, body);
|
|
2715
|
+
const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
|
|
2716
|
+
verdict: "an assistant save result",
|
|
2717
|
+
outcomes: ["kept", "created"],
|
|
2718
|
+
member: "example",
|
|
2719
|
+
parse: parseExactJson,
|
|
2720
|
+
readMember: (value) => this.readMethodExample("POST", endpoint, res, value, "an assistant save result's `example`"),
|
|
2721
|
+
});
|
|
2722
|
+
return { ...answer, outcome, example: subject };
|
|
2723
|
+
}
|
|
2495
2724
|
/**
|
|
2496
2725
|
* Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
|
|
2497
2726
|
* `204`. Deleting the example leaves its latest run in the method's history.
|
|
@@ -2666,7 +2895,8 @@ export class PipelexApiClient {
|
|
|
2666
2895
|
return downloadArtifactsImpl(this, request);
|
|
2667
2896
|
}
|
|
2668
2897
|
/**
|
|
2669
|
-
* Upload a base64 file — `POST /v1/upload
|
|
2898
|
+
* Upload a base64 file — `POST /v1/upload`, answering the file's `uri`, the `filename` as sent
|
|
2899
|
+
* and the `file_id` of its record. An answer with no non-empty string `uri` names no
|
|
2670
2900
|
* stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
|
|
2671
2901
|
* not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
|
|
2672
2902
|
*/
|
|
@@ -2681,18 +2911,24 @@ export class PipelexApiClient {
|
|
|
2681
2911
|
/**
|
|
2682
2912
|
* Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
|
|
2683
2913
|
* `upload` for a caller that holds the bytes but not this client's credential, such
|
|
2684
|
-
* as a browser page: the
|
|
2685
|
-
*
|
|
2686
|
-
*
|
|
2687
|
-
*
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
2690
|
-
*
|
|
2691
|
-
*
|
|
2692
|
-
*
|
|
2693
|
-
*
|
|
2694
|
-
*
|
|
2695
|
-
* `
|
|
2914
|
+
* as a browser page: the holder of the bytes describes the file with `describeUpload`,
|
|
2915
|
+
* its SHA-256 included, the credential-holding side asks for the grant with that
|
|
2916
|
+
* description and hands the grant over, and the holder of the bytes sends them with
|
|
2917
|
+
* `uploadWithGrant`. Both are on the browser-safe `@pipelex/sdk/upload` entry. The
|
|
2918
|
+
* bytes cross neither this client nor the API gateway, so the gateway's request quota
|
|
2919
|
+
* does not cap the file below the service's own limit (`max_bytes`).
|
|
2920
|
+
*
|
|
2921
|
+
* **Branch on the answer's `stored`.** With the file's `sha256`, the object is named
|
|
2922
|
+
* after its bytes: when the organization already holds them, the grant answers
|
|
2923
|
+
* `stored: true` with the file's `uri` and `file_id` and no upload target, and nothing
|
|
2924
|
+
* is to be sent. Otherwise, and always without a `sha256`, it answers a presigned,
|
|
2925
|
+
* create-only `PUT` whose `uri` exists once the `PUT` succeeds, and not before. That
|
|
2926
|
+
* target is short-lived and a bearer capability, so keep it out of logs. The route
|
|
2927
|
+
* never replays a grant: ask again for a new one rather than retrying; with a
|
|
2928
|
+
* `sha256`, a new grant answers the same file record. A declared `size` over the cap
|
|
2929
|
+
* is a `413` `ApiResponseError` (`code` `payload_too_large`), a malformed `sha256` and
|
|
2930
|
+
* a `size` other than the one the organization holds those bytes at are a `422`, and a
|
|
2931
|
+
* deployment without the route answers a `404`. See `docs/input-preparation.md`.
|
|
2696
2932
|
*/
|
|
2697
2933
|
async requestUploadGrant(input, options = {}) {
|
|
2698
2934
|
return this.requestProductObject("POST", "upload/grant", input, options);
|
|
@@ -2805,6 +3041,306 @@ export class PipelexApiClient {
|
|
|
2805
3041
|
async *iterateFiles(query = {}) {
|
|
2806
3042
|
yield* followCursor("listFiles", (cursor) => this.listFiles({ ...query, cursor }));
|
|
2807
3043
|
}
|
|
3044
|
+
/**
|
|
3045
|
+
* One file with its provenance, the methods whose lists hold it, and one page of its uses —
|
|
3046
|
+
* `GET /v1/files/{file_id}`.
|
|
3047
|
+
*
|
|
3048
|
+
* The answer is a `FileDetail`: the file's whole record (`FileRecord`), its `method_ids`, and
|
|
3049
|
+
* `uses`, one page of the examples that hold its address, then the runs that took it. Page the
|
|
3050
|
+
* uses by passing `uses.next_cursor` back as `usesCursor`; `null` is the last page. A record whose
|
|
3051
|
+
* upload looked unfinished past the upload grant's lifespan is settled by the read, so it answers
|
|
3052
|
+
* `stored` when its bytes are there.
|
|
3053
|
+
*
|
|
3054
|
+
* Throws `RequestArgumentError` for a file id that is empty, `.` or `..`, or holds a lone
|
|
3055
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for a file that is
|
|
3056
|
+
* not the organization's; `422` for a `usesLimit` outside 1 to 200 or an id that cannot be one.
|
|
3057
|
+
* An answer that is not an object, or whose `uses` is not a page of `items` and `next_cursor`, is
|
|
3058
|
+
* an answer the SDK cannot read, thrown as an `ApiResponseError` too. See `docs/files.md`.
|
|
3059
|
+
*/
|
|
3060
|
+
async getFile(fileId, query = {}) {
|
|
3061
|
+
const search = productQuery({ uses_limit: query.usesLimit, uses_cursor: query.usesCursor });
|
|
3062
|
+
const endpoint = `files/${idPathSegment(fileId, "getFile", FILE_ID_ARGUMENT)}${search}`;
|
|
3063
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3064
|
+
const detail = this.readObjectAnswer("GET", endpoint, res);
|
|
3065
|
+
const uses = detail.uses;
|
|
3066
|
+
if (!isPlainObject(uses) ||
|
|
3067
|
+
!Array.isArray(uses.items) ||
|
|
3068
|
+
(uses.next_cursor !== null && typeof uses.next_cursor !== "string")) {
|
|
3069
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a file whose `uses` is not a page of `items` and `next_cursor`");
|
|
3070
|
+
}
|
|
3071
|
+
return detail;
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* The records holding one `pipelex-storage://` address — `GET /v1/files/by-uri` — in the order a
|
|
3075
|
+
* surface picks the one to show, the first being it.
|
|
3076
|
+
*
|
|
3077
|
+
* Several records hold one address when several members uploaded the same bytes. With `runId`,
|
|
3078
|
+
* the records of the member who started that run come first, so a run's inputs show the name its
|
|
3079
|
+
* starter gave the file; then the caller's; then the rest, each group oldest first. A record
|
|
3080
|
+
* whose upload has not finished is answered with its `state`, `pending`. An address no record
|
|
3081
|
+
* holds answers an empty `items`, which is how a client tells a file stored before the platform
|
|
3082
|
+
* kept records.
|
|
3083
|
+
*
|
|
3084
|
+
* Throws `RequestArgumentError` for a `uri` that is not a non-empty string, before any request.
|
|
3085
|
+
* Throws `ApiResponseError`: `400` `invalid_storage_uri` for a malformed address, `403`
|
|
3086
|
+
* `forbidden` for another organization's, `404` `run_not_found` for a run that is not the
|
|
3087
|
+
* organization's, and `422` for an address over 512 characters. An answer whose `items` is not an
|
|
3088
|
+
* array is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
3089
|
+
*/
|
|
3090
|
+
async getFilesByUri(uri, options = {}) {
|
|
3091
|
+
const checkedUri = requireNonEmptyId(uri, "getFilesByUri", STORAGE_URI_ARGUMENT);
|
|
3092
|
+
const endpoint = `files/by-uri${productQuery({ uri: checkedUri, run_id: options.runId })}`;
|
|
3093
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3094
|
+
const answer = this.readObjectAnswer("GET", endpoint, res);
|
|
3095
|
+
if (!Array.isArray(answer.items)) {
|
|
3096
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "an answer whose `items` is not an array");
|
|
3097
|
+
}
|
|
3098
|
+
return answer;
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Rename a file — `PATCH /v1/files/{file_id}` — and return its record as it now stands.
|
|
3102
|
+
*
|
|
3103
|
+
* **The platform holds the name to one rule, for every client**: it removes the characters no
|
|
3104
|
+
* list or header can show (control and format characters, a right-to-left override among
|
|
3105
|
+
* them), trims the ends, refuses a name with nothing left, and adds the file's own extension,
|
|
3106
|
+
* the one its `original_name` ends with, when the name does not end with it, case aside:
|
|
3107
|
+
* `March invoice` becomes `March invoice.pdf`, and `March.docx` becomes `March.docx.pdf`. A file
|
|
3108
|
+
* whose original name has no extension gains none. Read the saved name from the answer's
|
|
3109
|
+
* `name`. The rename changes `name` alone, never the file's bytes, its address or its
|
|
3110
|
+
* `original_name`.
|
|
3111
|
+
*
|
|
3112
|
+
* An `admin` or a `member` renames any file; any other role, a runner among them, only the files
|
|
3113
|
+
* whose record names it as `created_by`: its own uploads and the outputs of the runs it started.
|
|
3114
|
+
*
|
|
3115
|
+
* Sends `name` alone: the body is closed. Throws `RequestArgumentError` for a file id that is
|
|
3116
|
+
* empty, `.` or `..`, or holds a lone surrogate, and for a `name` that is not a string, before
|
|
3117
|
+
* any request. Throws `ApiResponseError`: `403` `role_not_permitted` past the rule above; `404`
|
|
3118
|
+
* `not_found` for a file that is not the organization's; `422` for a name over 512 characters or
|
|
3119
|
+
* with nothing left once cleaned. An answer that is not an object is an answer the SDK cannot
|
|
3120
|
+
* read, thrown as an `ApiResponseError` too.
|
|
3121
|
+
*/
|
|
3122
|
+
async renameFile(fileId, input) {
|
|
3123
|
+
const segment = idPathSegment(fileId, "renameFile", FILE_ID_ARGUMENT);
|
|
3124
|
+
const { name } = input ?? {};
|
|
3125
|
+
if (typeof name !== "string") {
|
|
3126
|
+
throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name));
|
|
3127
|
+
}
|
|
3128
|
+
return this.requestProductObject("PATCH", `files/${segment}`, { name });
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Fresh links to read files — `POST /v1/files/links` — one per requested file, in the request's
|
|
3132
|
+
* order, each named by exactly one of its record's id (`file_id`) and its address (`uri`).
|
|
3133
|
+
*
|
|
3134
|
+
* **Branch on each item's `error`, never on the status**: the request is a `200` whenever every
|
|
3135
|
+
* file got a verdict. A linked file carries its `url`, a presigned `GET` valid for fifteen
|
|
3136
|
+
* minutes, with its `expires_at`, the `name` the download saves under and its `media_type`, and
|
|
3137
|
+
* a `null` `error`. A file that got no link carries `error`, `{ code, detail }`, and `null` link
|
|
3138
|
+
* members: `not_found` for an unknown file id, `conflict` for a file whose upload has not
|
|
3139
|
+
* finished, `invalid_storage_uri` for a malformed address and `forbidden` for another
|
|
3140
|
+
* organization's.
|
|
3141
|
+
*
|
|
3142
|
+
* Each link is signed with a `Content-Disposition` naming the file, so a download saves under
|
|
3143
|
+
* the name a person sees rather than the bytes' hash. It opens in place (`inline`) for an image,
|
|
3144
|
+
* a PDF or plain text and downloads (`attachment`) for every other type, SVG, HTML and XML
|
|
3145
|
+
* included, so a file that could run scripts never opens from a link. A file named by its id is
|
|
3146
|
+
* linked under its record's name; an address under the name of its first stored record, the
|
|
3147
|
+
* caller's before another member's, whose id the item's `file_id` names. A link is a bearer
|
|
3148
|
+
* capability: keep it out of logs, and ask again for a fresh one rather than storing it.
|
|
3149
|
+
*
|
|
3150
|
+
* Sends each file as `{ file_id }` or `{ uri }` and nothing else: the body is closed. Throws
|
|
3151
|
+
* `RequestArgumentError` before any request when `files` is not a list, or one of its items does
|
|
3152
|
+
* not name exactly one of `file_id` and `uri` as a non-empty string. Throws `ApiResponseError`:
|
|
3153
|
+
* `422` for more than 100 files, or an id or address longer than one can be; `500` `config` when
|
|
3154
|
+
* the platform cannot sign at all. An answer that is not one item per requested file, each an
|
|
3155
|
+
* object with its `url` or its `error`, is an answer the SDK cannot read, thrown as an
|
|
3156
|
+
* `ApiResponseError` whose body hides every link.
|
|
3157
|
+
*/
|
|
3158
|
+
async linkFiles(files) {
|
|
3159
|
+
if (!Array.isArray(files)) {
|
|
3160
|
+
throw argumentRefusal("linkFiles", "a list of files to link", describeNumberArgument(files));
|
|
3161
|
+
}
|
|
3162
|
+
const targets = files.map((target) => fileLinkTarget(target));
|
|
3163
|
+
const res = withLinks(await this.requestProductAnswer("POST", "files/links", { files: targets }));
|
|
3164
|
+
const answer = this.readObjectAnswer("POST", "files/links", res);
|
|
3165
|
+
const items = answer.items;
|
|
3166
|
+
if (!Array.isArray(items) || items.length !== targets.length) {
|
|
3167
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an answer that is not one item per requested file");
|
|
3168
|
+
}
|
|
3169
|
+
for (const item of items) {
|
|
3170
|
+
const linked = isPlainObject(item) && item.error === null && typeof item.url === "string";
|
|
3171
|
+
const refused = isPlainObject(item) && isPlainObject(item.error) && item.url === null;
|
|
3172
|
+
if (!linked && !refused) {
|
|
3173
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an item that carries neither a link nor an error");
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
return answer;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Ask for one zip of chosen files, or of a run's files — `POST /v1/files/archives` — and return
|
|
3180
|
+
* the archive, `queued`.
|
|
3181
|
+
*
|
|
3182
|
+
* The platform cannot build a zip inside a request, so the zip is two calls: this one queues the
|
|
3183
|
+
* archive and answers at once (`202`), and `getFileArchive` reads it until its `state` is `ready`,
|
|
3184
|
+
* whose `url` downloads the zip, or `failed`, whose `failure` says why. Name the files by exactly
|
|
3185
|
+
* one of `file_ids`, in the order the zip holds them, and `run_id`, whose zip holds what went in
|
|
3186
|
+
* and what came out of the run, never a file it made along the way nor an input given as a web
|
|
3187
|
+
* link. `name` is the name the zip downloads under (see `FileArchiveRequest`). An archive is never
|
|
3188
|
+
* a file: no list shows it, and it is gone a day after it was asked for.
|
|
3189
|
+
*
|
|
3190
|
+
* Sends `file_ids` or `run_id`, and `name` when given, and nothing else: the body is closed, and a
|
|
3191
|
+
* `null` member reads as not given. Throws `RequestArgumentError` before any request when the
|
|
3192
|
+
* request names both or neither of `file_ids` and `run_id`, when `file_ids` is not a non-empty
|
|
3193
|
+
* list of non-empty strings, when `run_id` is not a non-empty string, and when `name` is not a
|
|
3194
|
+
* string. Throws `ApiResponseError`:
|
|
3195
|
+
*
|
|
3196
|
+
* - `422` `file_archive_too_large` past a cap, at most 500 files or 2,000,000,000 bytes, whose
|
|
3197
|
+
* `problemDocument.limit` names it (`FileArchiveLimit`), so the person can be told how far over
|
|
3198
|
+
* the selection is;
|
|
3199
|
+
* - `422` `validation_failed` for more than 1,000 ids listed, or an id longer than one can be;
|
|
3200
|
+
* - `404` `not_found` for a file that is not the organization's, the `detail` naming up to five
|
|
3201
|
+
* of them, or a run whose files hold nothing stored to zip; `404` `run_not_found` for a run
|
|
3202
|
+
* that is not the organization's;
|
|
3203
|
+
* - `409` `conflict`: an `input` one, not retryable, for a selected file whose upload has not
|
|
3204
|
+
* finished; a `runtime` one, retryable, for a run whose files are not all recorded yet, which
|
|
3205
|
+
* the same request clears a few seconds after the run ends;
|
|
3206
|
+
* - `503` `service_unavailable`, retryable, when the archive could not be queued: the same request
|
|
3207
|
+
* sent again asks for a new archive.
|
|
3208
|
+
*
|
|
3209
|
+
* An answer that is not an archive, an object with a string `archive_id` and a known `state`, is
|
|
3210
|
+
* an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
3211
|
+
*/
|
|
3212
|
+
async requestFileArchive(request) {
|
|
3213
|
+
const call = "requestFileArchive";
|
|
3214
|
+
if (!isPlainObject(request)) {
|
|
3215
|
+
throw argumentRefusal(call, "an archive request, an object naming `file_ids` or `run_id`", describeNumberArgument(request));
|
|
3216
|
+
}
|
|
3217
|
+
const raw = request;
|
|
3218
|
+
const fileIds = raw.file_ids ?? undefined;
|
|
3219
|
+
const runId = raw.run_id ?? undefined;
|
|
3220
|
+
const name = raw.name ?? undefined;
|
|
3221
|
+
if ((fileIds === undefined) === (runId === undefined)) {
|
|
3222
|
+
throw new RequestArgumentError(`${call}() names the files to zip by exactly one of file_ids and run_id; got ` +
|
|
3223
|
+
`${fileIds === undefined ? "neither" : "both"}.`);
|
|
3224
|
+
}
|
|
3225
|
+
const body = {};
|
|
3226
|
+
if (fileIds !== undefined) {
|
|
3227
|
+
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
|
3228
|
+
throw argumentRefusal(call, "file_ids, a non-empty list of the file ids to zip", Array.isArray(fileIds) ? "an empty list" : describeNumberArgument(fileIds));
|
|
3229
|
+
}
|
|
3230
|
+
body.file_ids = fileIds.map((id) => requireNonEmptyId(id, call, ARCHIVE_FILE_ID_ARGUMENT));
|
|
3231
|
+
}
|
|
3232
|
+
else {
|
|
3233
|
+
body.run_id = requireNonEmptyId(runId, call, ARCHIVE_RUN_ID_ARGUMENT);
|
|
3234
|
+
}
|
|
3235
|
+
if (name !== undefined) {
|
|
3236
|
+
if (typeof name !== "string") {
|
|
3237
|
+
throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name));
|
|
3238
|
+
}
|
|
3239
|
+
body.name = name;
|
|
3240
|
+
}
|
|
3241
|
+
const res = await this.requestProductAnswer("POST", "files/archives", body);
|
|
3242
|
+
return this.readFileArchive("POST", "files/archives", res);
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Where an archive stands — `GET /v1/files/archives/{archive_id}` — with a fresh link to its zip
|
|
3246
|
+
* once it is `ready`.
|
|
3247
|
+
*
|
|
3248
|
+
* **Branch on `state`.** `queued` and `building` mean read it again shortly: a zip of a few files
|
|
3249
|
+
* is ready within seconds, and the largest the caps allow within a few minutes. `ready` carries
|
|
3250
|
+
* `url`, a presigned `GET` of the zip valid for fifteen minutes and signed with a
|
|
3251
|
+
* `Content-Disposition` naming the archive's `name`, with `archive_bytes` and `ready_at`; each read
|
|
3252
|
+
* signs a fresh link, so a caller that let one lapse reads the archive again. `failed` carries
|
|
3253
|
+
* `failure`, `file_gone` or `build_failed`, and the remedy for both is a new request. An archive
|
|
3254
|
+
* still `queued` or `building` an hour after it was asked for reads as `failed`.
|
|
3255
|
+
*
|
|
3256
|
+
* Throws `RequestArgumentError` for an archive id that is empty, `.` or `..`, or holds a lone
|
|
3257
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for an archive that
|
|
3258
|
+
* is not the organization's, never existed, or has expired, a day after `created_at`; `500` when
|
|
3259
|
+
* the link cannot be signed. An answer that is not an archive is an answer the SDK cannot read,
|
|
3260
|
+
* thrown as an `ApiResponseError` whose body hides the link.
|
|
3261
|
+
*/
|
|
3262
|
+
async getFileArchive(archiveId) {
|
|
3263
|
+
const segment = idPathSegment(archiveId, "getFileArchive", ARCHIVE_ID_ARGUMENT);
|
|
3264
|
+
const endpoint = `files/archives/${segment}`;
|
|
3265
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3266
|
+
return this.readFileArchive("GET", endpoint, res);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* An archive as `res` carried it, checked to be one: an object with a string `archive_id` and a
|
|
3270
|
+
* `state` this SDK knows, the field a caller branches on. Anything else is an answer the SDK
|
|
3271
|
+
* cannot read, thrown as the `ApiResponseError` `unreadableAnswer` builds with every link hidden,
|
|
3272
|
+
* since a ready archive's answer carries one.
|
|
3273
|
+
*/
|
|
3274
|
+
readFileArchive(method, endpoint, res) {
|
|
3275
|
+
const linked = withLinks(res);
|
|
3276
|
+
const archive = this.readObjectAnswer(method, endpoint, linked);
|
|
3277
|
+
if (typeof archive.archive_id !== "string") {
|
|
3278
|
+
throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, "an archive whose `archive_id` is not a string");
|
|
3279
|
+
}
|
|
3280
|
+
if (!FILE_ARCHIVE_STATES.includes(archive.state)) {
|
|
3281
|
+
throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, `an archive whose \`state\` is not ${inWords(FILE_ARCHIVE_STATES.map((state) => `\`${state}\``), "or")}`);
|
|
3282
|
+
}
|
|
3283
|
+
return archive;
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Copy one of the organization's files into a method's draft —
|
|
3287
|
+
* `POST /v1/methods/{id}/draft/files/from-file`, "Add to method files".
|
|
3288
|
+
*
|
|
3289
|
+
* The file becomes a file of the draft at `path`, replacing a file already there. Its bytes are
|
|
3290
|
+
* copied into the organization's blob store by the storage service itself, so they never cross
|
|
3291
|
+
* the client or the API whatever their size, and are skipped when the store already holds them.
|
|
3292
|
+
* The method file then lives apart from its source: erasing the file's record, the run that made
|
|
3293
|
+
* it or the method it came from leaves it readable.
|
|
3294
|
+
*
|
|
3295
|
+
* The answer is a `DraftFileFromFileResult`, a `200` discriminated on `blob`: `copied`, or
|
|
3296
|
+
* `already_held` when the store held the bytes and nothing was copied. Both carry the `file` as
|
|
3297
|
+
* the draft's tree now names it and the `method` as the draft write stored it, whose
|
|
3298
|
+
* `updated_at` is the new draft token. With `expected_updated_at`, the write is a
|
|
3299
|
+
* compare-and-swap on the draft token; without it, the file is added to the draft as it stands,
|
|
3300
|
+
* never erasing someone else's save.
|
|
3301
|
+
*
|
|
3302
|
+
* Takes a bare catalog id, as `writeDraft` does. Sends `file_id`, `path` and, when given,
|
|
3303
|
+
* `expected_updated_at`, and nothing else: the body is closed, and a `null` token reads as not
|
|
3304
|
+
* given. Throws `RequestArgumentError` before any request for a `file_id` or a `path` that is not
|
|
3305
|
+
* a non-empty string, and an `expected_updated_at` that is not a string. Throws
|
|
3306
|
+
* `ApiResponseError`: `403` `role_not_permitted` for a role that may not change methods; `404`
|
|
3307
|
+
* for an unknown method or a file that is not the organization's; `409` `method_being_deleted`,
|
|
3308
|
+
* `409` `method_update_conflict` for a stale `expected_updated_at`, and `409` `conflict` for a
|
|
3309
|
+
* file whose upload has not finished or whose stored bytes do not match its record; `422`
|
|
3310
|
+
* `invalid_method_files` for a path the draft cannot hold, such as `.env` or `../logo.png`, and
|
|
3311
|
+
* `422` `method_tree_too_large` past the deployment's limits. An answer whose `blob` is neither
|
|
3312
|
+
* arm's, or whose `file` or `method` is not an object, is an answer the SDK cannot read, thrown
|
|
3313
|
+
* as an `ApiResponseError` too.
|
|
3314
|
+
*/
|
|
3315
|
+
async copyFileIntoDraft(methodId, input) {
|
|
3316
|
+
const call = "copyFileIntoDraft";
|
|
3317
|
+
const endpoint = `${methodPath(methodId, call)}/draft/files/from-file`;
|
|
3318
|
+
const { file_id: fileId, path, expected_updated_at: token, } = input ?? {};
|
|
3319
|
+
const body = {
|
|
3320
|
+
file_id: requireNonEmptyId(fileId, call, FILE_ID_ARGUMENT),
|
|
3321
|
+
path: requireNonEmptyId(path, call, DRAFT_PATH_ARGUMENT),
|
|
3322
|
+
};
|
|
3323
|
+
if (token != null) {
|
|
3324
|
+
if (typeof token !== "string") {
|
|
3325
|
+
throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
|
|
3326
|
+
}
|
|
3327
|
+
body.expected_updated_at = token;
|
|
3328
|
+
}
|
|
3329
|
+
const res = await this.requestProductAnswer("POST", endpoint, body);
|
|
3330
|
+
const answer = this.readObjectAnswer("POST", endpoint, res);
|
|
3331
|
+
let fault;
|
|
3332
|
+
if (!DRAFT_FILE_BLOBS.includes(answer.blob)) {
|
|
3333
|
+
fault = `whose \`blob\` is not ${inWords(DRAFT_FILE_BLOBS.map((blob) => `\`${blob}\``), "or")}`;
|
|
3334
|
+
}
|
|
3335
|
+
else if (!isPlainObject(answer.file))
|
|
3336
|
+
fault = "whose `file` is not an object";
|
|
3337
|
+
else if (!isPlainObject(answer.method))
|
|
3338
|
+
fault = "whose `method` is not an object";
|
|
3339
|
+
if (fault !== undefined) {
|
|
3340
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, `a copy into a draft ${fault}`);
|
|
3341
|
+
}
|
|
3342
|
+
return answer;
|
|
3343
|
+
}
|
|
2808
3344
|
/**
|
|
2809
3345
|
* One PAGE of a method's runs, newest first — `GET /v1/runs?method_id=`.
|
|
2810
3346
|
*
|
|
@@ -2903,6 +3439,51 @@ export class PipelexApiClient {
|
|
|
2903
3439
|
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
2904
3440
|
return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
|
|
2905
3441
|
}
|
|
3442
|
+
/**
|
|
3443
|
+
* One page of a run's files, sorted by where they sit in the run — `GET /v1/runs/{id}/files`.
|
|
3444
|
+
*
|
|
3445
|
+
* Each entry carries its `mark`, `input`, `final` or `intermediate`, and the record a surface
|
|
3446
|
+
* shows for it (`RunFileEntry`): `inputs` are what went in, the inputs given by their address
|
|
3447
|
+
* with the record of the member who started the run first, then the inline inputs; `outputs` are
|
|
3448
|
+
* the run's final outputs; `intermediates` the files it made along the way. An input given as a
|
|
3449
|
+
* web link gets no record and is listed in `links`, with the warning that replaying the run needs
|
|
3450
|
+
* it to still serve the same file.
|
|
3451
|
+
*
|
|
3452
|
+
* **`state` is `listing` until the platform has recorded every file of the run**, a few seconds
|
|
3453
|
+
* after it ends: read it again until it is `listed` before taking the list as whole. The run's
|
|
3454
|
+
* own records are paged by address with `limit` and `cursor`; the inputs by address and the links
|
|
3455
|
+
* come on the first page only. Pass `nextCursor` back as `cursor`; `null` is the last page.
|
|
3456
|
+
*
|
|
3457
|
+
* Throws `RequestArgumentError` for a run id that is empty, `.` or `..`, or holds a lone
|
|
3458
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` for a run that is not the
|
|
3459
|
+
* organization's, exactly as for one that does not exist; `400` for a run id holding a character
|
|
3460
|
+
* outside letters, digits, `_` and `-`, or a cursor another listing issued; `422` for a `limit`
|
|
3461
|
+
* outside 1 to 200. A page whose `state` is unknown, whose lists are not arrays, or whose `next_cursor` is neither a
|
|
3462
|
+
* string nor `null`, is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
3463
|
+
*/
|
|
3464
|
+
async listRunFiles(runId, query = {}) {
|
|
3465
|
+
const segment = idPathSegment(runId, "listRunFiles", RUN_ID_ARGUMENT);
|
|
3466
|
+
const search = productQuery({ limit: query.limit, cursor: query.cursor });
|
|
3467
|
+
const endpoint = `runs/${segment}/files${search}`;
|
|
3468
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3469
|
+
const page = this.readObjectAnswer("GET", endpoint, res);
|
|
3470
|
+
const { next_cursor: nextCursor, ...rest } = page;
|
|
3471
|
+
const notAList = RUN_FILES_LISTS.find((list) => !Array.isArray(page[list]));
|
|
3472
|
+
let fault;
|
|
3473
|
+
if (!RUN_FILES_STATES.includes(page.state)) {
|
|
3474
|
+
fault = "whose `state` is not `listing` or `listed`";
|
|
3475
|
+
}
|
|
3476
|
+
else if (notAList !== undefined) {
|
|
3477
|
+
fault = `whose \`${notAList}\` is not an array`;
|
|
3478
|
+
}
|
|
3479
|
+
else if (nextCursor !== null && typeof nextCursor !== "string") {
|
|
3480
|
+
fault = "whose `next_cursor` is neither a string nor null";
|
|
3481
|
+
}
|
|
3482
|
+
if (fault !== undefined) {
|
|
3483
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, `a page of a run's files ${fault}`);
|
|
3484
|
+
}
|
|
3485
|
+
return { ...rest, nextCursor };
|
|
3486
|
+
}
|
|
2906
3487
|
/** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
|
|
2907
3488
|
async updateRun(runId, input) {
|
|
2908
3489
|
const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);
|
|
@@ -3339,7 +3920,7 @@ function assertLinkageMethodIdIsBare(options, treeForms) {
|
|
|
3339
3920
|
function nonEmptyString(value) {
|
|
3340
3921
|
return value != null && value.length > 0 ? value : undefined;
|
|
3341
3922
|
}
|
|
3342
|
-
/** The request header the hosted API reads a
|
|
3923
|
+
/** The request header the hosted API reads a request's idempotency key from. */
|
|
3343
3924
|
const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
3344
3925
|
/**
|
|
3345
3926
|
* The longest idempotency key a start sends. It is room for any generated key, and keeps the
|
|
@@ -3354,21 +3935,22 @@ const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
|
|
|
3354
3935
|
*/
|
|
3355
3936
|
const VISIBLE_ASCII = /^[\x21-\x7E]+$/;
|
|
3356
3937
|
/**
|
|
3357
|
-
* The idempotency key a start sends, or `undefined` when the options carry none
|
|
3358
|
-
* `null`). A key that is not a string of 1 to 255 visible ASCII characters is refused
|
|
3359
|
-
* `RequestArgumentError` rather than dropped, an empty one included, since a
|
|
3360
|
-
* the key its caller gave would lose the protection the caller asked for without a word.
|
|
3361
|
-
*
|
|
3362
|
-
*
|
|
3938
|
+
* The idempotency key a start or a method copy sends, or `undefined` when the options carry none
|
|
3939
|
+
* (absent or `null`). A key that is not a string of 1 to 255 visible ASCII characters is refused
|
|
3940
|
+
* with a `RequestArgumentError` rather than dropped, an empty one included, since a request sent
|
|
3941
|
+
* without the key its caller gave would lose the protection the caller asked for without a word.
|
|
3942
|
+
* `request` says what the request does, as the refusal's last sentence names it ("start the run",
|
|
3943
|
+
* "copy the method"). The message never quotes the key, which a caller may derive from something
|
|
3944
|
+
* private. The Python SDK holds a key to the same rule, in the same words.
|
|
3363
3945
|
*/
|
|
3364
|
-
function idempotencyKeyOf(options) {
|
|
3946
|
+
function idempotencyKeyOf(options, request = "start the run") {
|
|
3365
3947
|
const key = options.idempotencyKey;
|
|
3366
3948
|
if (key == null)
|
|
3367
3949
|
return undefined;
|
|
3368
3950
|
const fault = idempotencyKeyFault(key);
|
|
3369
3951
|
if (fault !== undefined) {
|
|
3370
3952
|
throw new RequestArgumentError(`The idempotency key must be 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} visible ASCII characters ` +
|
|
3371
|
-
`(\`!\` to \`~\`), and this one ${fault}. Leave it out to
|
|
3953
|
+
`(\`!\` to \`~\`), and this one ${fault}. Leave it out to ${request} without one.`);
|
|
3372
3954
|
}
|
|
3373
3955
|
return key;
|
|
3374
3956
|
}
|