@pipelex/sdk 0.48.1 → 0.50.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 +29 -11
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +341 -18
- package/dist/client.js +615 -28
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +100 -12
- package/dist/errors.js +49 -7
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/prepare-inputs.d.ts +12 -1
- package/dist/prepare-inputs.js +26 -3
- package/dist/prepare-inputs.js.map +1 -1
- package/dist/product-models.d.ts +677 -22
- package/dist/product-models.js +2 -2
- 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/upload.d.ts +39 -11
- package/dist/upload.js +115 -8
- package/dist/upload.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +7 -5
- package/docs/errors.md +12 -3
- package/docs/files.md +258 -0
- package/docs/input-preparation.md +57 -23
- package/docs/method-examples.md +36 -3
- package/docs/organization-branding.md +102 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@ import { PipelexExecuteResult, resultsFromExecute } from "./execute-result.js";
|
|
|
14
14
|
import { ExactJsonUnsupportedError, parseExactJson, stringifyExactJson } from "./exact-json.js";
|
|
15
15
|
import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
|
|
16
16
|
// A pure RUNAWAY guard on the paged-list iterators (`iterateMethods`,
|
|
17
|
-
// `iterateRuns`, `iterateMethodExamples`), deliberately not a coverage limit.
|
|
17
|
+
// `iterateRuns`, `iterateMethodExamples`, `iterateFiles`), deliberately not a coverage limit.
|
|
18
18
|
//
|
|
19
19
|
// It counts TOTAL pages, not empty ones. An earlier version capped consecutive
|
|
20
20
|
// empty pages, which is the wrong axis: an empty page whose cursor ADVANCED is
|
|
@@ -31,8 +31,8 @@ import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
|
|
|
31
31
|
const MAX_PAGES = 10_000;
|
|
32
32
|
/**
|
|
33
33
|
* Follow a paged list's cursor until the server says there is no more, yielding every row —
|
|
34
|
-
* the one loop behind `iterateMethods`, `iterateRuns
|
|
35
|
-
* drift apart.
|
|
34
|
+
* the one loop behind `iterateMethods`, `iterateRuns`, `iterateMethodExamples` and
|
|
35
|
+
* `iterateFiles`, so they cannot drift apart.
|
|
36
36
|
*
|
|
37
37
|
* It yields lazily: a caller that breaks early stops the requests. It stops when `nextCursor`
|
|
38
38
|
* is `null`, the normal end, and when the server hands back the cursor it was just sent, which
|
|
@@ -186,6 +186,24 @@ function productQuery(params) {
|
|
|
186
186
|
const text = search.toString();
|
|
187
187
|
return text ? `?${text}` : "";
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* A list filter sent as one repeated query parameter per value, such as `listFiles`' `format`:
|
|
191
|
+
* `undefined` when omitted, the values as given otherwise. An empty list names no value, and sent
|
|
192
|
+
* as no parameter at all it would read as no filter and list everything, so it is a
|
|
193
|
+
* `RequestArgumentError` before any request; a value that is not a list of strings is a
|
|
194
|
+
* `TypeError`, a bug in the calling code.
|
|
195
|
+
*/
|
|
196
|
+
function repeatedFilter(call, name, values) {
|
|
197
|
+
if (values === undefined)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) {
|
|
200
|
+
throw new TypeError(`${call}() takes "${name}" as a list of strings.`);
|
|
201
|
+
}
|
|
202
|
+
if (values.length === 0) {
|
|
203
|
+
throw new RequestArgumentError(`${call}() takes "${name}" as one or more values; omit it to list every ${name}.`);
|
|
204
|
+
}
|
|
205
|
+
return values;
|
|
206
|
+
}
|
|
189
207
|
/** `items` joined as a sentence names them, the last after `conjunction`: `a`, `a or b`, `a, b or c`. */
|
|
190
208
|
function inWords(items, conjunction) {
|
|
191
209
|
if (items.length < 2)
|
|
@@ -198,9 +216,23 @@ const EXAMPLE_ID_ARGUMENT = "an example id, the example's non-empty `example_id`
|
|
|
198
216
|
const RUN_ID_ARGUMENT = "a run id, the run's non-empty `pipeline_run_id`";
|
|
199
217
|
const ORG_ID_ARGUMENT = "an organization id, the organization's non-empty `org_id`";
|
|
200
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`";
|
|
201
221
|
/** What the run id of each example write is, as a refusal of it names it. */
|
|
202
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";
|
|
203
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";
|
|
204
236
|
/**
|
|
205
237
|
* The refusal of an argument a call cannot take, worded as every such refusal is:
|
|
206
238
|
* `<call>() takes <what>; got <got>.`, with what made it unusable as `cause` when there is one.
|
|
@@ -225,6 +257,22 @@ function requireNonEmptyId(value, call, what) {
|
|
|
225
257
|
got = value === null ? "null" : typeof value;
|
|
226
258
|
throw argumentRefusal(call, what, got);
|
|
227
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The check the example writes that take a name and inputs share, the create and the assistant's
|
|
262
|
+
* save: a `name` or `inputs` not given, a `null` counting as not given, is refused with
|
|
263
|
+
* `RequestArgumentError` before anything is sent, `call` naming the method in the message and
|
|
264
|
+
* `needs` what it needs.
|
|
265
|
+
*/
|
|
266
|
+
function requireNameAndInputs(name, inputs, call, needs) {
|
|
267
|
+
if (name != null && inputs != null)
|
|
268
|
+
return;
|
|
269
|
+
let got;
|
|
270
|
+
if (name == null && inputs == null)
|
|
271
|
+
got = "neither";
|
|
272
|
+
else
|
|
273
|
+
got = name == null ? "no name" : "no inputs";
|
|
274
|
+
throw new RequestArgumentError(`${call}() needs ${needs}; got ${got}.`);
|
|
275
|
+
}
|
|
228
276
|
/**
|
|
229
277
|
* A caller's value as the refusal of a number argument names it: a number by its value, anything
|
|
230
278
|
* else by its type, as `requireNonEmptyId` names a value, and `null` as itself. Never by `String`,
|
|
@@ -289,6 +337,35 @@ function methodExamplePath(methodId, exampleId, call) {
|
|
|
289
337
|
return `${path}/examples/${idPathSegment(exampleId, call, EXAMPLE_ID_ARGUMENT)}`;
|
|
290
338
|
}
|
|
291
339
|
const isString = (value) => typeof value === "string";
|
|
340
|
+
/** The states an archive is in, the field a caller branches on, in the order a refusal lists them. */
|
|
341
|
+
const FILE_ARCHIVE_STATES = ["queued", "building", "ready", "failed"];
|
|
342
|
+
/** What a copy into a draft did in the blob store, the field its answer is discriminated on. */
|
|
343
|
+
const DRAFT_FILE_BLOBS = ["copied", "already_held"];
|
|
344
|
+
/** Whether a run's records are all written, the field a reader of its files branches on. */
|
|
345
|
+
const RUN_FILES_STATES = ["listing", "listed"];
|
|
346
|
+
/** The lists of a page of a run's files, each an array on every page. */
|
|
347
|
+
const RUN_FILES_LISTS = ["inputs", "outputs", "intermediates", "links"];
|
|
348
|
+
/**
|
|
349
|
+
* One file `linkFiles` is asked to link, checked and reduced to what the closed body takes:
|
|
350
|
+
* `{ file_id }` or `{ uri }`, exactly one of them a non-empty string. Anything else, an object
|
|
351
|
+
* naming both, neither or an empty one included, is refused with `RequestArgumentError` before
|
|
352
|
+
* anything is sent, as the platform would refuse the whole request with a `422`.
|
|
353
|
+
*/
|
|
354
|
+
function fileLinkTarget(value) {
|
|
355
|
+
if (!isPlainObject(value)) {
|
|
356
|
+
throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, describeNumberArgument(value));
|
|
357
|
+
}
|
|
358
|
+
const fileId = value.file_id ?? undefined;
|
|
359
|
+
const uri = value.uri ?? undefined;
|
|
360
|
+
if ((fileId === undefined) === (uri === undefined)) {
|
|
361
|
+
const got = fileId === undefined ? "an object naming neither" : "an object naming both";
|
|
362
|
+
throw argumentRefusal("linkFiles", LINK_TARGET_ARGUMENT, got);
|
|
363
|
+
}
|
|
364
|
+
if (fileId !== undefined) {
|
|
365
|
+
return { file_id: requireNonEmptyId(fileId, "linkFiles", LINK_TARGET_ARGUMENT) };
|
|
366
|
+
}
|
|
367
|
+
return { uri: requireNonEmptyId(uri, "linkFiles", LINK_TARGET_ARGUMENT) };
|
|
368
|
+
}
|
|
292
369
|
/**
|
|
293
370
|
* Each field of a `MethodExample`, what it must be, and the check it must pass: every field the
|
|
294
371
|
* Python SDK's model requires, held to the same strict types it holds them to, so both SDKs refuse
|
|
@@ -313,6 +390,7 @@ const METHOD_EXAMPLE_FIELDS = {
|
|
|
313
390
|
created_by_user_id: ["a string", isString],
|
|
314
391
|
created_at: ["a string", isString],
|
|
315
392
|
updated_at: ["a string", isString],
|
|
393
|
+
kept_by_assistant: ["a boolean", (value) => typeof value === "boolean"],
|
|
316
394
|
};
|
|
317
395
|
export class PipelexApiClient {
|
|
318
396
|
apiKey;
|
|
@@ -552,7 +630,7 @@ export class PipelexApiClient {
|
|
|
552
630
|
}
|
|
553
631
|
/**
|
|
554
632
|
* One page of a cursor-paged product route (`listMethods`, `listMethodVersions`,
|
|
555
|
-
* `listMethodExamples`, `listRuns`), read as the platform always serializes it: `items` an
|
|
633
|
+
* `listMethodExamples`, `listRuns`, `listFiles`), read as the platform always serializes it: `items` an
|
|
556
634
|
* array, `next_cursor` a string or `null`. A page breaking either is an answer the SDK cannot
|
|
557
635
|
* read, so it throws the `ApiResponseError` `unreadableAnswer` builds, `runtime` and not
|
|
558
636
|
* retryable, rather than handing the iterators a page they cannot walk: `items` that is not
|
|
@@ -2319,14 +2397,7 @@ export class PipelexApiClient {
|
|
|
2319
2397
|
async createMethodExample(methodId, input) {
|
|
2320
2398
|
const endpoint = `${methodPath(methodId, "createMethodExample")}/examples`;
|
|
2321
2399
|
const { name, inputs, run_id: runId, } = input ?? {};
|
|
2322
|
-
|
|
2323
|
-
let got;
|
|
2324
|
-
if (name == null && inputs == null)
|
|
2325
|
-
got = "neither";
|
|
2326
|
-
else
|
|
2327
|
-
got = name == null ? "no name" : "no inputs";
|
|
2328
|
-
throw new RequestArgumentError(`createMethodExample() needs the example's name and its inputs; got ${got}.`);
|
|
2329
|
-
}
|
|
2400
|
+
requireNameAndInputs(name, inputs, "createMethodExample", "the example's name and its inputs");
|
|
2330
2401
|
const body = { name, inputs };
|
|
2331
2402
|
if (runId != null) {
|
|
2332
2403
|
body.run_id = requireNonEmptyId(runId, "createMethodExample", CREATE_RUN_ID_ARGUMENT);
|
|
@@ -2474,6 +2545,64 @@ export class PipelexApiClient {
|
|
|
2474
2545
|
});
|
|
2475
2546
|
return { ...answer, outcome, example: subject };
|
|
2476
2547
|
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Save the build assistant's test inputs as its example of the method —
|
|
2550
|
+
* `POST /v1/methods/{id}/examples/assistant-save` — **never writing over a person's edit**.
|
|
2551
|
+
*
|
|
2552
|
+
* The assistant keeps one example of a method current with its test inputs, until a person
|
|
2553
|
+
* edits that example's inputs. Find its example as the method's newest with `kept_by_assistant`
|
|
2554
|
+
* true (`listMethodExamples` answers them oldest first), and send its `example_id` with the
|
|
2555
|
+
* `input_revision` you read it at. The platform writes the inputs into it only while it still
|
|
2556
|
+
* exists, still carries the flag and still holds that revision, in one conditional write that it
|
|
2557
|
+
* never retries. Otherwise, and when no example is named, which is how the first save on a
|
|
2558
|
+
* method creates the assistant's example, it saves the inputs as a new example flagged as the
|
|
2559
|
+
* assistant's, under `name`.
|
|
2560
|
+
*
|
|
2561
|
+
* The answer is a `MethodExampleAssistantSaveResult` discriminated on `outcome` — branch on it:
|
|
2562
|
+
* `kept`, the named example now holds the inputs, its name untouched; `created`, a new example
|
|
2563
|
+
* holds them. Both carry the `example`, which is the assistant's from then on. A retry of a save
|
|
2564
|
+
* that landed answers `kept` and writes nothing again.
|
|
2565
|
+
*
|
|
2566
|
+
* Sends `name`, `inputs` and, when given, `example_id` with `input_revision`, and nothing else:
|
|
2567
|
+
* the body is closed, and `null` reads as not given. Throws `RequestArgumentError` before any
|
|
2568
|
+
* request when `name` or `inputs` is not given, for an `example_id` that is not a non-empty
|
|
2569
|
+
* string or an `input_revision` that is not a positive integer, and when only one of the two is
|
|
2570
|
+
* given. Throws `ApiResponseError`: `403` `role_not_permitted` for a role that may not change
|
|
2571
|
+
* methods, such as a runner; `413` `payload_too_large` for an example larger than one stored item
|
|
2572
|
+
* holds; `422` for a name that is empty, blank or over 200 characters, or inputs that are not a
|
|
2573
|
+
* JSON object; and the codes every example route answers. An answer whose `outcome` is neither
|
|
2574
|
+
* arm's, or whose `example` is not an example `readMethodExample` can read, is an answer the SDK
|
|
2575
|
+
* cannot read, thrown as an `ApiResponseError` too.
|
|
2576
|
+
*/
|
|
2577
|
+
async saveAssistantMethodExample(methodId, input) {
|
|
2578
|
+
const call = "saveAssistantMethodExample";
|
|
2579
|
+
const endpoint = `${methodPath(methodId, call)}/examples/assistant-save`;
|
|
2580
|
+
const { name, inputs, example_id: exampleId, input_revision: revision, } = input ?? {};
|
|
2581
|
+
requireNameAndInputs(name, inputs, call, "the name a new example takes and the inputs");
|
|
2582
|
+
const body = { name, inputs };
|
|
2583
|
+
if (exampleId != null) {
|
|
2584
|
+
body.example_id = requireNonEmptyId(exampleId, call, ASSISTANT_EXAMPLE_ID_ARGUMENT);
|
|
2585
|
+
}
|
|
2586
|
+
if (revision != null) {
|
|
2587
|
+
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
|
|
2588
|
+
throw argumentRefusal(call, ASSISTANT_REVISION_ARGUMENT, describeNumberArgument(revision));
|
|
2589
|
+
}
|
|
2590
|
+
body.input_revision = revision;
|
|
2591
|
+
}
|
|
2592
|
+
if ((body.example_id === undefined) !== (body.input_revision === undefined)) {
|
|
2593
|
+
throw new RequestArgumentError(`${call}() names the assistant's example by both its example_id and the input_revision ` +
|
|
2594
|
+
`it was read at, or neither; got only ${body.example_id === undefined ? "input_revision" : "example_id"}.`);
|
|
2595
|
+
}
|
|
2596
|
+
const res = await this.requestProductAnswer("POST", endpoint, body);
|
|
2597
|
+
const { answer, outcome, subject } = this.readVerdict("POST", endpoint, res, {
|
|
2598
|
+
verdict: "an assistant save result",
|
|
2599
|
+
outcomes: ["kept", "created"],
|
|
2600
|
+
member: "example",
|
|
2601
|
+
parse: parseExactJson,
|
|
2602
|
+
readMember: (value) => this.readMethodExample("POST", endpoint, res, value, "an assistant save result's `example`"),
|
|
2603
|
+
});
|
|
2604
|
+
return { ...answer, outcome, example: subject };
|
|
2605
|
+
}
|
|
2477
2606
|
/**
|
|
2478
2607
|
* Delete an example — `DELETE /v1/methods/{id}/examples/{example_id}`. The platform answers
|
|
2479
2608
|
* `204`. Deleting the example leaves its latest run in the method's history.
|
|
@@ -2499,6 +2628,47 @@ export class PipelexApiClient {
|
|
|
2499
2628
|
const segment = idPathSegment(orgId, "renameOrganization", ORG_ID_ARGUMENT);
|
|
2500
2629
|
return this.requestProduct("PATCH", `organizations/${segment}`, input);
|
|
2501
2630
|
}
|
|
2631
|
+
/**
|
|
2632
|
+
* The branding of the caller's active organization — `GET /v1/branding`: its name, its light
|
|
2633
|
+
* and dark colour sets and its two logos. Any role reads it, `runner` included, by web session
|
|
2634
|
+
* or API key, so an app scaffolded from the method-app template reads its organization's
|
|
2635
|
+
* branding with its own key. A personal workspace, or a team that set nothing, answers every
|
|
2636
|
+
* colour and logo `null`. A logo's `uri` is a `pipelex-storage://` reference, which
|
|
2637
|
+
* `resolveStorageUrl` turns into a short-lived link to its bytes.
|
|
2638
|
+
*
|
|
2639
|
+
* Throws `ApiResponseError`: `400` when the caller has no active organization, `404` when its
|
|
2640
|
+
* row is missing, and an answer that is not a JSON object, an empty one included.
|
|
2641
|
+
*/
|
|
2642
|
+
async getBranding() {
|
|
2643
|
+
return this.requestProductObject("GET", "branding");
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Set or clear an organization's branding — `PATCH /v1/organizations/{org_id}/branding`, an
|
|
2647
|
+
* admin's write to the active organization. Every key of `input` is optional: a value sets it,
|
|
2648
|
+
* `null` clears it, and a key left out keeps its value (`OrganizationBrandingUpdate`), so `{}`
|
|
2649
|
+
* writes nothing. A new logo is the `pipelex-storage://` URI of a file the organization uploaded
|
|
2650
|
+
* on its own, which the platform reads and measures, then copies under `branding/`: the answer's
|
|
2651
|
+
* `uri` is that copy, and sending it back unchanged keeps the logo. The logo a write replaces or
|
|
2652
|
+
* clears is erased. Answers the branding as the write left it.
|
|
2653
|
+
*
|
|
2654
|
+
* Throws `RequestArgumentError` for an organization id that is empty, `.` or `..`, before any
|
|
2655
|
+
* request. Throws `ApiResponseError`, refusing every change of the request when it refuses one:
|
|
2656
|
+
* `403` `forbidden` when `orgId` is not the active organization, `403` `role_not_permitted` for
|
|
2657
|
+
* a caller who is not its admin, `404` for a missing organization, `409` `conflict` for a
|
|
2658
|
+
* personal workspace, which has no branding, and `422` `validation_failed` for a colour that is
|
|
2659
|
+
* not `#rrggbb` or a key the body does not know. A logo's refusal names its slot in the error's
|
|
2660
|
+
* `errors[]`, each entry's `field` being `square_logo` or `wide_logo` and its `detail` the
|
|
2661
|
+
* sentence to show under that slot: `400` `invalid_storage_uri` for a URI that is not a storage
|
|
2662
|
+
* URI, `403` `forbidden` for another organization's file, and `422` `validation_failed` with a
|
|
2663
|
+
* `LogoRefusalCode` per broken rule for a file the platform refused. A failure of the platform's
|
|
2664
|
+
* storage while reading or copying a logo is a `500` `storage_backend_failed`, retryable, with
|
|
2665
|
+
* nothing written. A `503` `service_unavailable` is a failure of its database, after which the
|
|
2666
|
+
* write may have landed or not: the same input sent again is safe.
|
|
2667
|
+
*/
|
|
2668
|
+
async updateOrganizationBranding(orgId, input) {
|
|
2669
|
+
const segment = idPathSegment(orgId, "updateOrganizationBranding", ORG_ID_ARGUMENT);
|
|
2670
|
+
return this.requestProductObject("PATCH", `organizations/${segment}/branding`, input);
|
|
2671
|
+
}
|
|
2502
2672
|
/** The active org's subscription state — `GET /v1/billing/subscription`. */
|
|
2503
2673
|
async getSubscription() {
|
|
2504
2674
|
return this.requestProduct("GET", "billing/subscription");
|
|
@@ -2607,7 +2777,8 @@ export class PipelexApiClient {
|
|
|
2607
2777
|
return downloadArtifactsImpl(this, request);
|
|
2608
2778
|
}
|
|
2609
2779
|
/**
|
|
2610
|
-
* Upload a base64 file — `POST /v1/upload
|
|
2780
|
+
* Upload a base64 file — `POST /v1/upload`, answering the file's `uri`, the `filename` as sent
|
|
2781
|
+
* and the `file_id` of its record. An answer with no non-empty string `uri` names no
|
|
2611
2782
|
* stored file, so it is an answer the SDK cannot read: an `ApiResponseError`, `runtime` and
|
|
2612
2783
|
* not retryable, which `uploadFile` wraps as an `UploadTransportError` with code `unexpected`.
|
|
2613
2784
|
*/
|
|
@@ -2622,18 +2793,24 @@ export class PipelexApiClient {
|
|
|
2622
2793
|
/**
|
|
2623
2794
|
* Request a grant to upload one file straight to storage — `POST /v1/upload/grant`.
|
|
2624
2795
|
* `upload` for a caller that holds the bytes but not this client's credential, such
|
|
2625
|
-
* as a browser page: the
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
2630
|
-
*
|
|
2631
|
-
*
|
|
2632
|
-
*
|
|
2633
|
-
*
|
|
2634
|
-
*
|
|
2635
|
-
*
|
|
2636
|
-
* `
|
|
2796
|
+
* as a browser page: the holder of the bytes describes the file with `describeUpload`,
|
|
2797
|
+
* its SHA-256 included, the credential-holding side asks for the grant with that
|
|
2798
|
+
* description and hands the grant over, and the holder of the bytes sends them with
|
|
2799
|
+
* `uploadWithGrant`. Both are on the browser-safe `@pipelex/sdk/upload` entry. The
|
|
2800
|
+
* bytes cross neither this client nor the API gateway, so the gateway's request quota
|
|
2801
|
+
* does not cap the file below the service's own limit (`max_bytes`).
|
|
2802
|
+
*
|
|
2803
|
+
* **Branch on the answer's `stored`.** With the file's `sha256`, the object is named
|
|
2804
|
+
* after its bytes: when the organization already holds them, the grant answers
|
|
2805
|
+
* `stored: true` with the file's `uri` and `file_id` and no upload target, and nothing
|
|
2806
|
+
* is to be sent. Otherwise, and always without a `sha256`, it answers a presigned,
|
|
2807
|
+
* create-only `PUT` whose `uri` exists once the `PUT` succeeds, and not before. That
|
|
2808
|
+
* target is short-lived and a bearer capability, so keep it out of logs. The route
|
|
2809
|
+
* never replays a grant: ask again for a new one rather than retrying; with a
|
|
2810
|
+
* `sha256`, a new grant answers the same file record. A declared `size` over the cap
|
|
2811
|
+
* is a `413` `ApiResponseError` (`code` `payload_too_large`), a malformed `sha256` and
|
|
2812
|
+
* a `size` other than the one the organization holds those bytes at are a `422`, and a
|
|
2813
|
+
* deployment without the route answers a `404`. See `docs/input-preparation.md`.
|
|
2637
2814
|
*/
|
|
2638
2815
|
async requestUploadGrant(input, options = {}) {
|
|
2639
2816
|
return this.requestProductObject("POST", "upload/grant", input, options);
|
|
@@ -2642,9 +2819,11 @@ export class PipelexApiClient {
|
|
|
2642
2819
|
* Upload one local asset and return its {@link UploadRecord} — the single-asset
|
|
2643
2820
|
* convenience over {@link upload}. Accepts `Blob`/`File`/`ArrayBuffer`/`Uint8Array`
|
|
2644
2821
|
* in every runtime; a path string is Node-only (it fails instructively elsewhere).
|
|
2645
|
-
* The record guarantees `uri`, `contentType`, `size`, and `filename`.
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2822
|
+
* The record guarantees `uri`, `contentType`, `size`, and `filename`. A `kind` and its
|
|
2823
|
+
* `method_id` in the options record the upload as a method's test file, checked before the
|
|
2824
|
+
* asset is read. Transport failures surface as the semantic input-preparation errors
|
|
2825
|
+
* (rejected asset, refused test file, auth, unsupported capability, transport). See
|
|
2826
|
+
* `docs/input-preparation.md`.
|
|
2648
2827
|
*/
|
|
2649
2828
|
async uploadFile(asset, options) {
|
|
2650
2829
|
return uploadFileImpl(this, asset, options);
|
|
@@ -2681,6 +2860,369 @@ export class PipelexApiClient {
|
|
|
2681
2860
|
async prepareInputs(request) {
|
|
2682
2861
|
return prepareInputsImpl(this, request);
|
|
2683
2862
|
}
|
|
2863
|
+
/**
|
|
2864
|
+
* One page of the organization's files, newest first — `GET /v1/files`.
|
|
2865
|
+
*
|
|
2866
|
+
* Rows are `FileSummary`: the record's id, its `uri`, which a run's inputs take as they take
|
|
2867
|
+
* any address, the name a person sees, its type, size and kind, and where it came from. A list
|
|
2868
|
+
* never holds a file a run made along the way, nor an upload whose bytes have not been seen
|
|
2869
|
+
* stored. Every role of the organization reads every file of it.
|
|
2870
|
+
*
|
|
2871
|
+
* `methodId`, `runId` and `createdBy` choose which list serves the page: the method's files
|
|
2872
|
+
* (what its runs and examples took or made), the run's own files (its final outputs and inline
|
|
2873
|
+
* inputs, in the order of their addresses), one member's (`"me"` for the caller's own), or the
|
|
2874
|
+
* organization's. `q`, `kind`, `format`, `mediaType`, `createdFrom` and `createdTo` filter that
|
|
2875
|
+
* list server-side; `format` and `mediaType` are sent as one repeated parameter per value, and
|
|
2876
|
+
* an empty list of either is a `RequestArgumentError` before any request. The filters run after
|
|
2877
|
+
* the index read, so **a filtered page can hold fewer rows than `limit`, or none, and still
|
|
2878
|
+
* carry a `nextCursor`**: keep following it until it is `null`, with the same filters, since a
|
|
2879
|
+
* cursor is bound to the list and filters that issued it.
|
|
2880
|
+
*
|
|
2881
|
+
* Throws `ApiResponseError`: `400` for a date bound that is not an instant with an offset, a
|
|
2882
|
+
* range whose start is after its end, or a cursor of another list or other filters; `404`
|
|
2883
|
+
* `run_not_found` for a run that is not the organization's; `422` for a `limit` outside 1 to
|
|
2884
|
+
* 200, an unknown `kind`, more than 100 values of `format` or `mediaType`, or an id that cannot
|
|
2885
|
+
* be one. A page whose `items` is not an array or whose `next_cursor` is neither a string nor
|
|
2886
|
+
* `null` is an `ApiResponseError` of an answer the SDK cannot read. See `docs/files.md`.
|
|
2887
|
+
*/
|
|
2888
|
+
async listFiles(query = {}) {
|
|
2889
|
+
const search = productQuery({
|
|
2890
|
+
method_id: query.methodId,
|
|
2891
|
+
run_id: query.runId,
|
|
2892
|
+
created_by: query.createdBy,
|
|
2893
|
+
kind: query.kind,
|
|
2894
|
+
format: repeatedFilter("listFiles", "format", query.format),
|
|
2895
|
+
media_type: repeatedFilter("listFiles", "mediaType", query.mediaType),
|
|
2896
|
+
created_from: query.createdFrom,
|
|
2897
|
+
created_to: query.createdTo,
|
|
2898
|
+
q: query.q,
|
|
2899
|
+
limit: query.limit,
|
|
2900
|
+
cursor: query.cursor,
|
|
2901
|
+
});
|
|
2902
|
+
return this.requestPage(`files${search}`);
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Every file of the organization the query selects, streamed — follows the cursor for you.
|
|
2906
|
+
*
|
|
2907
|
+
* ```ts
|
|
2908
|
+
* for await (const file of client.iterateFiles({ kind: "uploaded", format: ["pdf"] })) {
|
|
2909
|
+
* if (file.name === "March invoice.pdf") break; // stop whenever you like
|
|
2910
|
+
* }
|
|
2911
|
+
* ```
|
|
2912
|
+
*
|
|
2913
|
+
* **Prefer `listFiles`** for anything user-facing: this makes as many round trips as the data
|
|
2914
|
+
* demands. It is an iterator rather than an all-at-once helper for the reason `iterateRuns` is
|
|
2915
|
+
* one: a helper returning an array needs a page cap, and a cap returns a truncated list with no
|
|
2916
|
+
* error.
|
|
2917
|
+
*
|
|
2918
|
+
* It keeps going **through an empty page that carries a cursor**, which under any filter means
|
|
2919
|
+
* "nothing matched in the slice just read", not "done", sending the same filters with every
|
|
2920
|
+
* page. It stops on a `null` cursor or on a cursor the server failed to advance, and throws
|
|
2921
|
+
* `PagingNotTerminatingError` past a page ceiling no real file list reaches.
|
|
2922
|
+
*/
|
|
2923
|
+
async *iterateFiles(query = {}) {
|
|
2924
|
+
yield* followCursor("listFiles", (cursor) => this.listFiles({ ...query, cursor }));
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* One file with its provenance, the methods whose lists hold it, and one page of its uses —
|
|
2928
|
+
* `GET /v1/files/{file_id}`.
|
|
2929
|
+
*
|
|
2930
|
+
* The answer is a `FileDetail`: the file's whole record (`FileRecord`), its `method_ids`, and
|
|
2931
|
+
* `uses`, one page of the examples that hold its address, then the runs that took it. Page the
|
|
2932
|
+
* uses by passing `uses.next_cursor` back as `usesCursor`; `null` is the last page. A record whose
|
|
2933
|
+
* upload looked unfinished past the upload grant's lifespan is settled by the read, so it answers
|
|
2934
|
+
* `stored` when its bytes are there.
|
|
2935
|
+
*
|
|
2936
|
+
* Throws `RequestArgumentError` for a file id that is empty, `.` or `..`, or holds a lone
|
|
2937
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for a file that is
|
|
2938
|
+
* not the organization's; `422` for a `usesLimit` outside 1 to 200 or an id that cannot be one.
|
|
2939
|
+
* An answer that is not an object, or whose `uses` is not a page of `items` and `next_cursor`, is
|
|
2940
|
+
* an answer the SDK cannot read, thrown as an `ApiResponseError` too. See `docs/files.md`.
|
|
2941
|
+
*/
|
|
2942
|
+
async getFile(fileId, query = {}) {
|
|
2943
|
+
const search = productQuery({ uses_limit: query.usesLimit, uses_cursor: query.usesCursor });
|
|
2944
|
+
const endpoint = `files/${idPathSegment(fileId, "getFile", FILE_ID_ARGUMENT)}${search}`;
|
|
2945
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
2946
|
+
const detail = this.readObjectAnswer("GET", endpoint, res);
|
|
2947
|
+
const uses = detail.uses;
|
|
2948
|
+
if (!isPlainObject(uses) ||
|
|
2949
|
+
!Array.isArray(uses.items) ||
|
|
2950
|
+
(uses.next_cursor !== null && typeof uses.next_cursor !== "string")) {
|
|
2951
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "a file whose `uses` is not a page of `items` and `next_cursor`");
|
|
2952
|
+
}
|
|
2953
|
+
return detail;
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* The records holding one `pipelex-storage://` address — `GET /v1/files/by-uri` — in the order a
|
|
2957
|
+
* surface picks the one to show, the first being it.
|
|
2958
|
+
*
|
|
2959
|
+
* Several records hold one address when several members uploaded the same bytes. With `runId`,
|
|
2960
|
+
* the records of the member who started that run come first, so a run's inputs show the name its
|
|
2961
|
+
* starter gave the file; then the caller's; then the rest, each group oldest first. A record
|
|
2962
|
+
* whose upload has not finished is answered with its `state`, `pending`. An address no record
|
|
2963
|
+
* holds answers an empty `items`, which is how a client tells a file stored before the platform
|
|
2964
|
+
* kept records.
|
|
2965
|
+
*
|
|
2966
|
+
* Throws `RequestArgumentError` for a `uri` that is not a non-empty string, before any request.
|
|
2967
|
+
* Throws `ApiResponseError`: `400` `invalid_storage_uri` for a malformed address, `403`
|
|
2968
|
+
* `forbidden` for another organization's, `404` `run_not_found` for a run that is not the
|
|
2969
|
+
* organization's, and `422` for an address over 512 characters. An answer whose `items` is not an
|
|
2970
|
+
* array is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
2971
|
+
*/
|
|
2972
|
+
async getFilesByUri(uri, options = {}) {
|
|
2973
|
+
const checkedUri = requireNonEmptyId(uri, "getFilesByUri", STORAGE_URI_ARGUMENT);
|
|
2974
|
+
const endpoint = `files/by-uri${productQuery({ uri: checkedUri, run_id: options.runId })}`;
|
|
2975
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
2976
|
+
const answer = this.readObjectAnswer("GET", endpoint, res);
|
|
2977
|
+
if (!Array.isArray(answer.items)) {
|
|
2978
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, "an answer whose `items` is not an array");
|
|
2979
|
+
}
|
|
2980
|
+
return answer;
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Rename a file — `PATCH /v1/files/{file_id}` — and return its record as it now stands.
|
|
2984
|
+
*
|
|
2985
|
+
* **The platform holds the name to one rule, for every client**: it removes the characters no
|
|
2986
|
+
* list or header can show (control and format characters, a right-to-left override among
|
|
2987
|
+
* them), trims the ends, refuses a name with nothing left, and adds the file's own extension,
|
|
2988
|
+
* the one its `original_name` ends with, when the name does not end with it, case aside:
|
|
2989
|
+
* `March invoice` becomes `March invoice.pdf`, and `March.docx` becomes `March.docx.pdf`. A file
|
|
2990
|
+
* whose original name has no extension gains none. Read the saved name from the answer's
|
|
2991
|
+
* `name`. The rename changes `name` alone, never the file's bytes, its address or its
|
|
2992
|
+
* `original_name`.
|
|
2993
|
+
*
|
|
2994
|
+
* An `admin` or a `member` renames any file; any other role, a runner among them, only the files
|
|
2995
|
+
* whose record names it as `created_by`: its own uploads and the outputs of the runs it started.
|
|
2996
|
+
*
|
|
2997
|
+
* Sends `name` alone: the body is closed. Throws `RequestArgumentError` for a file id that is
|
|
2998
|
+
* empty, `.` or `..`, or holds a lone surrogate, and for a `name` that is not a string, before
|
|
2999
|
+
* any request. Throws `ApiResponseError`: `403` `role_not_permitted` past the rule above; `404`
|
|
3000
|
+
* `not_found` for a file that is not the organization's; `422` for a name over 512 characters or
|
|
3001
|
+
* with nothing left once cleaned. An answer that is not an object is an answer the SDK cannot
|
|
3002
|
+
* read, thrown as an `ApiResponseError` too.
|
|
3003
|
+
*/
|
|
3004
|
+
async renameFile(fileId, input) {
|
|
3005
|
+
const segment = idPathSegment(fileId, "renameFile", FILE_ID_ARGUMENT);
|
|
3006
|
+
const { name } = input ?? {};
|
|
3007
|
+
if (typeof name !== "string") {
|
|
3008
|
+
throw argumentRefusal("renameFile", RENAME_NAME_ARGUMENT, describeNumberArgument(name));
|
|
3009
|
+
}
|
|
3010
|
+
return this.requestProductObject("PATCH", `files/${segment}`, { name });
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* Fresh links to read files — `POST /v1/files/links` — one per requested file, in the request's
|
|
3014
|
+
* order, each named by exactly one of its record's id (`file_id`) and its address (`uri`).
|
|
3015
|
+
*
|
|
3016
|
+
* **Branch on each item's `error`, never on the status**: the request is a `200` whenever every
|
|
3017
|
+
* file got a verdict. A linked file carries its `url`, a presigned `GET` valid for fifteen
|
|
3018
|
+
* minutes, with its `expires_at`, the `name` the download saves under and its `media_type`, and
|
|
3019
|
+
* a `null` `error`. A file that got no link carries `error`, `{ code, detail }`, and `null` link
|
|
3020
|
+
* members: `not_found` for an unknown file id, `conflict` for a file whose upload has not
|
|
3021
|
+
* finished, `invalid_storage_uri` for a malformed address and `forbidden` for another
|
|
3022
|
+
* organization's.
|
|
3023
|
+
*
|
|
3024
|
+
* Each link is signed with a `Content-Disposition` naming the file, so a download saves under
|
|
3025
|
+
* the name a person sees rather than the bytes' hash. It opens in place (`inline`) for an image,
|
|
3026
|
+
* a PDF or plain text and downloads (`attachment`) for every other type, SVG, HTML and XML
|
|
3027
|
+
* included, so a file that could run scripts never opens from a link. A file named by its id is
|
|
3028
|
+
* linked under its record's name; an address under the name of its first stored record, the
|
|
3029
|
+
* caller's before another member's, whose id the item's `file_id` names. A link is a bearer
|
|
3030
|
+
* capability: keep it out of logs, and ask again for a fresh one rather than storing it.
|
|
3031
|
+
*
|
|
3032
|
+
* Sends each file as `{ file_id }` or `{ uri }` and nothing else: the body is closed. Throws
|
|
3033
|
+
* `RequestArgumentError` before any request when `files` is not a list, or one of its items does
|
|
3034
|
+
* not name exactly one of `file_id` and `uri` as a non-empty string. Throws `ApiResponseError`:
|
|
3035
|
+
* `422` for more than 100 files, or an id or address longer than one can be; `500` `config` when
|
|
3036
|
+
* the platform cannot sign at all. An answer that is not one item per requested file, each an
|
|
3037
|
+
* object with its `url` or its `error`, is an answer the SDK cannot read, thrown as an
|
|
3038
|
+
* `ApiResponseError` whose body hides every link.
|
|
3039
|
+
*/
|
|
3040
|
+
async linkFiles(files) {
|
|
3041
|
+
if (!Array.isArray(files)) {
|
|
3042
|
+
throw argumentRefusal("linkFiles", "a list of files to link", describeNumberArgument(files));
|
|
3043
|
+
}
|
|
3044
|
+
const targets = files.map((target) => fileLinkTarget(target));
|
|
3045
|
+
const res = withLinks(await this.requestProductAnswer("POST", "files/links", { files: targets }));
|
|
3046
|
+
const answer = this.readObjectAnswer("POST", "files/links", res);
|
|
3047
|
+
const items = answer.items;
|
|
3048
|
+
if (!Array.isArray(items) || items.length !== targets.length) {
|
|
3049
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an answer that is not one item per requested file");
|
|
3050
|
+
}
|
|
3051
|
+
for (const item of items) {
|
|
3052
|
+
const linked = isPlainObject(item) && item.error === null && typeof item.url === "string";
|
|
3053
|
+
const refused = isPlainObject(item) && isPlainObject(item.error) && item.url === null;
|
|
3054
|
+
if (!linked && !refused) {
|
|
3055
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/files/links`, res, "an item that carries neither a link nor an error");
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
return answer;
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Ask for one zip of chosen files, or of a run's files — `POST /v1/files/archives` — and return
|
|
3062
|
+
* the archive, `queued`.
|
|
3063
|
+
*
|
|
3064
|
+
* The platform cannot build a zip inside a request, so the zip is two calls: this one queues the
|
|
3065
|
+
* archive and answers at once (`202`), and `getFileArchive` reads it until its `state` is `ready`,
|
|
3066
|
+
* whose `url` downloads the zip, or `failed`, whose `failure` says why. Name the files by exactly
|
|
3067
|
+
* one of `file_ids`, in the order the zip holds them, and `run_id`, whose zip holds what went in
|
|
3068
|
+
* and what came out of the run, never a file it made along the way nor an input given as a web
|
|
3069
|
+
* link. `name` is the name the zip downloads under (see `FileArchiveRequest`). An archive is never
|
|
3070
|
+
* a file: no list shows it, and it is gone a day after it was asked for.
|
|
3071
|
+
*
|
|
3072
|
+
* Sends `file_ids` or `run_id`, and `name` when given, and nothing else: the body is closed, and a
|
|
3073
|
+
* `null` member reads as not given. Throws `RequestArgumentError` before any request when the
|
|
3074
|
+
* request names both or neither of `file_ids` and `run_id`, when `file_ids` is not a non-empty
|
|
3075
|
+
* list of non-empty strings, when `run_id` is not a non-empty string, and when `name` is not a
|
|
3076
|
+
* string. Throws `ApiResponseError`:
|
|
3077
|
+
*
|
|
3078
|
+
* - `422` `file_archive_too_large` past a cap, at most 500 files or 2,000,000,000 bytes, whose
|
|
3079
|
+
* `problemDocument.limit` names it (`FileArchiveLimit`), so the person can be told how far over
|
|
3080
|
+
* the selection is;
|
|
3081
|
+
* - `422` `validation_failed` for more than 1,000 ids listed, or an id longer than one can be;
|
|
3082
|
+
* - `404` `not_found` for a file that is not the organization's, the `detail` naming up to five
|
|
3083
|
+
* of them, or a run whose files hold nothing stored to zip; `404` `run_not_found` for a run
|
|
3084
|
+
* that is not the organization's;
|
|
3085
|
+
* - `409` `conflict`: an `input` one, not retryable, for a selected file whose upload has not
|
|
3086
|
+
* finished; a `runtime` one, retryable, for a run whose files are not all recorded yet, which
|
|
3087
|
+
* the same request clears a few seconds after the run ends;
|
|
3088
|
+
* - `503` `service_unavailable`, retryable, when the archive could not be queued: the same request
|
|
3089
|
+
* sent again asks for a new archive.
|
|
3090
|
+
*
|
|
3091
|
+
* An answer that is not an archive, an object with a string `archive_id` and a known `state`, is
|
|
3092
|
+
* an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
3093
|
+
*/
|
|
3094
|
+
async requestFileArchive(request) {
|
|
3095
|
+
const call = "requestFileArchive";
|
|
3096
|
+
if (!isPlainObject(request)) {
|
|
3097
|
+
throw argumentRefusal(call, "an archive request, an object naming `file_ids` or `run_id`", describeNumberArgument(request));
|
|
3098
|
+
}
|
|
3099
|
+
const raw = request;
|
|
3100
|
+
const fileIds = raw.file_ids ?? undefined;
|
|
3101
|
+
const runId = raw.run_id ?? undefined;
|
|
3102
|
+
const name = raw.name ?? undefined;
|
|
3103
|
+
if ((fileIds === undefined) === (runId === undefined)) {
|
|
3104
|
+
throw new RequestArgumentError(`${call}() names the files to zip by exactly one of file_ids and run_id; got ` +
|
|
3105
|
+
`${fileIds === undefined ? "neither" : "both"}.`);
|
|
3106
|
+
}
|
|
3107
|
+
const body = {};
|
|
3108
|
+
if (fileIds !== undefined) {
|
|
3109
|
+
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
|
3110
|
+
throw argumentRefusal(call, "file_ids, a non-empty list of the file ids to zip", Array.isArray(fileIds) ? "an empty list" : describeNumberArgument(fileIds));
|
|
3111
|
+
}
|
|
3112
|
+
body.file_ids = fileIds.map((id) => requireNonEmptyId(id, call, ARCHIVE_FILE_ID_ARGUMENT));
|
|
3113
|
+
}
|
|
3114
|
+
else {
|
|
3115
|
+
body.run_id = requireNonEmptyId(runId, call, ARCHIVE_RUN_ID_ARGUMENT);
|
|
3116
|
+
}
|
|
3117
|
+
if (name !== undefined) {
|
|
3118
|
+
if (typeof name !== "string") {
|
|
3119
|
+
throw argumentRefusal(call, ARCHIVE_NAME_ARGUMENT, describeNumberArgument(name));
|
|
3120
|
+
}
|
|
3121
|
+
body.name = name;
|
|
3122
|
+
}
|
|
3123
|
+
const res = await this.requestProductAnswer("POST", "files/archives", body);
|
|
3124
|
+
return this.readFileArchive("POST", "files/archives", res);
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Where an archive stands — `GET /v1/files/archives/{archive_id}` — with a fresh link to its zip
|
|
3128
|
+
* once it is `ready`.
|
|
3129
|
+
*
|
|
3130
|
+
* **Branch on `state`.** `queued` and `building` mean read it again shortly: a zip of a few files
|
|
3131
|
+
* is ready within seconds, and the largest the caps allow within a few minutes. `ready` carries
|
|
3132
|
+
* `url`, a presigned `GET` of the zip valid for fifteen minutes and signed with a
|
|
3133
|
+
* `Content-Disposition` naming the archive's `name`, with `archive_bytes` and `ready_at`; each read
|
|
3134
|
+
* signs a fresh link, so a caller that let one lapse reads the archive again. `failed` carries
|
|
3135
|
+
* `failure`, `file_gone` or `build_failed`, and the remedy for both is a new request. An archive
|
|
3136
|
+
* still `queued` or `building` an hour after it was asked for reads as `failed`.
|
|
3137
|
+
*
|
|
3138
|
+
* Throws `RequestArgumentError` for an archive id that is empty, `.` or `..`, or holds a lone
|
|
3139
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` `not_found` for an archive that
|
|
3140
|
+
* is not the organization's, never existed, or has expired, a day after `created_at`; `500` when
|
|
3141
|
+
* the link cannot be signed. An answer that is not an archive is an answer the SDK cannot read,
|
|
3142
|
+
* thrown as an `ApiResponseError` whose body hides the link.
|
|
3143
|
+
*/
|
|
3144
|
+
async getFileArchive(archiveId) {
|
|
3145
|
+
const segment = idPathSegment(archiveId, "getFileArchive", ARCHIVE_ID_ARGUMENT);
|
|
3146
|
+
const endpoint = `files/archives/${segment}`;
|
|
3147
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3148
|
+
return this.readFileArchive("GET", endpoint, res);
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* An archive as `res` carried it, checked to be one: an object with a string `archive_id` and a
|
|
3152
|
+
* `state` this SDK knows, the field a caller branches on. Anything else is an answer the SDK
|
|
3153
|
+
* cannot read, thrown as the `ApiResponseError` `unreadableAnswer` builds with every link hidden,
|
|
3154
|
+
* since a ready archive's answer carries one.
|
|
3155
|
+
*/
|
|
3156
|
+
readFileArchive(method, endpoint, res) {
|
|
3157
|
+
const linked = withLinks(res);
|
|
3158
|
+
const archive = this.readObjectAnswer(method, endpoint, linked);
|
|
3159
|
+
if (typeof archive.archive_id !== "string") {
|
|
3160
|
+
throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, "an archive whose `archive_id` is not a string");
|
|
3161
|
+
}
|
|
3162
|
+
if (!FILE_ARCHIVE_STATES.includes(archive.state)) {
|
|
3163
|
+
throw this.unreadableAnswer(method, `/${API_PREFIX}/${endpoint}`, linked, `an archive whose \`state\` is not ${inWords(FILE_ARCHIVE_STATES.map((state) => `\`${state}\``), "or")}`);
|
|
3164
|
+
}
|
|
3165
|
+
return archive;
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* Copy one of the organization's files into a method's draft —
|
|
3169
|
+
* `POST /v1/methods/{id}/draft/files/from-file`, "Add to method files".
|
|
3170
|
+
*
|
|
3171
|
+
* The file becomes a file of the draft at `path`, replacing a file already there. Its bytes are
|
|
3172
|
+
* copied into the organization's blob store by the storage service itself, so they never cross
|
|
3173
|
+
* the client or the API whatever their size, and are skipped when the store already holds them.
|
|
3174
|
+
* The method file then lives apart from its source: erasing the file's record, the run that made
|
|
3175
|
+
* it or the method it came from leaves it readable.
|
|
3176
|
+
*
|
|
3177
|
+
* The answer is a `DraftFileFromFileResult`, a `200` discriminated on `blob`: `copied`, or
|
|
3178
|
+
* `already_held` when the store held the bytes and nothing was copied. Both carry the `file` as
|
|
3179
|
+
* the draft's tree now names it and the `method` as the draft write stored it, whose
|
|
3180
|
+
* `updated_at` is the new draft token. With `expected_updated_at`, the write is a
|
|
3181
|
+
* compare-and-swap on the draft token; without it, the file is added to the draft as it stands,
|
|
3182
|
+
* never erasing someone else's save.
|
|
3183
|
+
*
|
|
3184
|
+
* Takes a bare catalog id, as `writeDraft` does. Sends `file_id`, `path` and, when given,
|
|
3185
|
+
* `expected_updated_at`, and nothing else: the body is closed, and a `null` token reads as not
|
|
3186
|
+
* given. Throws `RequestArgumentError` before any request for a `file_id` or a `path` that is not
|
|
3187
|
+
* a non-empty string, and an `expected_updated_at` that is not a string. Throws
|
|
3188
|
+
* `ApiResponseError`: `403` `role_not_permitted` for a role that may not change methods; `404`
|
|
3189
|
+
* for an unknown method or a file that is not the organization's; `409` `method_being_deleted`,
|
|
3190
|
+
* `409` `method_update_conflict` for a stale `expected_updated_at`, and `409` `conflict` for a
|
|
3191
|
+
* file whose upload has not finished or whose stored bytes do not match its record; `422`
|
|
3192
|
+
* `invalid_method_files` for a path the draft cannot hold, such as `.env` or `../logo.png`, and
|
|
3193
|
+
* `422` `method_tree_too_large` past the deployment's limits. An answer whose `blob` is neither
|
|
3194
|
+
* arm's, or whose `file` or `method` is not an object, is an answer the SDK cannot read, thrown
|
|
3195
|
+
* as an `ApiResponseError` too.
|
|
3196
|
+
*/
|
|
3197
|
+
async copyFileIntoDraft(methodId, input) {
|
|
3198
|
+
const call = "copyFileIntoDraft";
|
|
3199
|
+
const endpoint = `${methodPath(methodId, call)}/draft/files/from-file`;
|
|
3200
|
+
const { file_id: fileId, path, expected_updated_at: token, } = input ?? {};
|
|
3201
|
+
const body = {
|
|
3202
|
+
file_id: requireNonEmptyId(fileId, call, FILE_ID_ARGUMENT),
|
|
3203
|
+
path: requireNonEmptyId(path, call, DRAFT_PATH_ARGUMENT),
|
|
3204
|
+
};
|
|
3205
|
+
if (token != null) {
|
|
3206
|
+
if (typeof token !== "string") {
|
|
3207
|
+
throw argumentRefusal(call, DRAFT_TOKEN_ARGUMENT, describeNumberArgument(token));
|
|
3208
|
+
}
|
|
3209
|
+
body.expected_updated_at = token;
|
|
3210
|
+
}
|
|
3211
|
+
const res = await this.requestProductAnswer("POST", endpoint, body);
|
|
3212
|
+
const answer = this.readObjectAnswer("POST", endpoint, res);
|
|
3213
|
+
let fault;
|
|
3214
|
+
if (!DRAFT_FILE_BLOBS.includes(answer.blob)) {
|
|
3215
|
+
fault = `whose \`blob\` is not ${inWords(DRAFT_FILE_BLOBS.map((blob) => `\`${blob}\``), "or")}`;
|
|
3216
|
+
}
|
|
3217
|
+
else if (!isPlainObject(answer.file))
|
|
3218
|
+
fault = "whose `file` is not an object";
|
|
3219
|
+
else if (!isPlainObject(answer.method))
|
|
3220
|
+
fault = "whose `method` is not an object";
|
|
3221
|
+
if (fault !== undefined) {
|
|
3222
|
+
throw this.unreadableAnswer("POST", `/${API_PREFIX}/${endpoint}`, res, `a copy into a draft ${fault}`);
|
|
3223
|
+
}
|
|
3224
|
+
return answer;
|
|
3225
|
+
}
|
|
2684
3226
|
/**
|
|
2685
3227
|
* One PAGE of a method's runs, newest first — `GET /v1/runs?method_id=`.
|
|
2686
3228
|
*
|
|
@@ -2779,6 +3321,51 @@ export class PipelexApiClient {
|
|
|
2779
3321
|
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
2780
3322
|
return withCheckedReport(this.readObjectAnswer("GET", endpoint, res, {}, parseExactJson));
|
|
2781
3323
|
}
|
|
3324
|
+
/**
|
|
3325
|
+
* One page of a run's files, sorted by where they sit in the run — `GET /v1/runs/{id}/files`.
|
|
3326
|
+
*
|
|
3327
|
+
* Each entry carries its `mark`, `input`, `final` or `intermediate`, and the record a surface
|
|
3328
|
+
* shows for it (`RunFileEntry`): `inputs` are what went in, the inputs given by their address
|
|
3329
|
+
* with the record of the member who started the run first, then the inline inputs; `outputs` are
|
|
3330
|
+
* the run's final outputs; `intermediates` the files it made along the way. An input given as a
|
|
3331
|
+
* web link gets no record and is listed in `links`, with the warning that replaying the run needs
|
|
3332
|
+
* it to still serve the same file.
|
|
3333
|
+
*
|
|
3334
|
+
* **`state` is `listing` until the platform has recorded every file of the run**, a few seconds
|
|
3335
|
+
* after it ends: read it again until it is `listed` before taking the list as whole. The run's
|
|
3336
|
+
* own records are paged by address with `limit` and `cursor`; the inputs by address and the links
|
|
3337
|
+
* come on the first page only. Pass `nextCursor` back as `cursor`; `null` is the last page.
|
|
3338
|
+
*
|
|
3339
|
+
* Throws `RequestArgumentError` for a run id that is empty, `.` or `..`, or holds a lone
|
|
3340
|
+
* surrogate, before any request. Throws `ApiResponseError`: `404` for a run that is not the
|
|
3341
|
+
* organization's, exactly as for one that does not exist; `400` for a run id holding a character
|
|
3342
|
+
* outside letters, digits, `_` and `-`, or a cursor another listing issued; `422` for a `limit`
|
|
3343
|
+
* outside 1 to 200. A page whose `state` is unknown, whose lists are not arrays, or whose `next_cursor` is neither a
|
|
3344
|
+
* string nor `null`, is an answer the SDK cannot read, thrown as an `ApiResponseError` too.
|
|
3345
|
+
*/
|
|
3346
|
+
async listRunFiles(runId, query = {}) {
|
|
3347
|
+
const segment = idPathSegment(runId, "listRunFiles", RUN_ID_ARGUMENT);
|
|
3348
|
+
const search = productQuery({ limit: query.limit, cursor: query.cursor });
|
|
3349
|
+
const endpoint = `runs/${segment}/files${search}`;
|
|
3350
|
+
const res = await this.requestProductAnswer("GET", endpoint, undefined);
|
|
3351
|
+
const page = this.readObjectAnswer("GET", endpoint, res);
|
|
3352
|
+
const { next_cursor: nextCursor, ...rest } = page;
|
|
3353
|
+
const notAList = RUN_FILES_LISTS.find((list) => !Array.isArray(page[list]));
|
|
3354
|
+
let fault;
|
|
3355
|
+
if (!RUN_FILES_STATES.includes(page.state)) {
|
|
3356
|
+
fault = "whose `state` is not `listing` or `listed`";
|
|
3357
|
+
}
|
|
3358
|
+
else if (notAList !== undefined) {
|
|
3359
|
+
fault = `whose \`${notAList}\` is not an array`;
|
|
3360
|
+
}
|
|
3361
|
+
else if (nextCursor !== null && typeof nextCursor !== "string") {
|
|
3362
|
+
fault = "whose `next_cursor` is neither a string nor null";
|
|
3363
|
+
}
|
|
3364
|
+
if (fault !== undefined) {
|
|
3365
|
+
throw this.unreadableAnswer("GET", `/${API_PREFIX}/${endpoint}`, res, `a page of a run's files ${fault}`);
|
|
3366
|
+
}
|
|
3367
|
+
return { ...rest, nextCursor };
|
|
3368
|
+
}
|
|
2782
3369
|
/** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
|
|
2783
3370
|
async updateRun(runId, input) {
|
|
2784
3371
|
const segment = idPathSegment(runId, "updateRun", RUN_ID_ARGUMENT);
|