@convilyn/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -6,10 +6,13 @@ var fs = require('fs');
6
6
  var path = require('path');
7
7
 
8
8
  // src/version.ts
9
- var VERSION = "0.2.0";
9
+ var VERSION = "0.4.0";
10
10
 
11
11
  // src/errors.ts
12
+ var CONVILYN_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@convilyn/sdk.ConvilynError");
12
13
  var ConvilynError = class extends Error {
14
+ /** @internal brand marker — see {@link isConvilynError} */
15
+ [CONVILYN_ERROR_BRAND] = true;
13
16
  constructor(message) {
14
17
  super(message);
15
18
  this.name = "ConvilynError";
@@ -115,14 +118,22 @@ var GoalJobTimeoutError = class extends ConvilynError {
115
118
  jobSpecId;
116
119
  elapsed;
117
120
  timeout;
121
+ /**
122
+ * `'total'` — the overall `timeout` budget lapsed; `'idle'` —
123
+ * `idleTimeout` lapsed with no status/progress change (the job may still
124
+ * be healthy on a long phase; `retrieve()` before assuming failure).
125
+ */
126
+ reason;
118
127
  constructor(opts) {
128
+ const reason = opts.reason ?? "total";
119
129
  super(
120
- `GoalJob ${opts.jobSpecId} did not reach a terminal status within ${opts.timeout}s (elapsed ${opts.elapsed.toFixed(1)}s)`
130
+ reason === "idle" ? `GoalJob ${opts.jobSpecId} showed no status/progress change for ${opts.timeout}s (elapsed ${opts.elapsed.toFixed(1)}s total)` : `GoalJob ${opts.jobSpecId} did not reach a terminal status within ${opts.timeout}s (elapsed ${opts.elapsed.toFixed(1)}s)`
121
131
  );
122
132
  this.name = "GoalJobTimeoutError";
123
133
  this.jobSpecId = opts.jobSpecId;
124
134
  this.elapsed = opts.elapsed;
125
135
  this.timeout = opts.timeout;
136
+ this.reason = reason;
126
137
  }
127
138
  };
128
139
  var WebSocketError = class extends ConvilynError {
@@ -722,6 +733,29 @@ function parseGoalJob(wire) {
722
733
  completedAt: toDateOrNull(wire.completedAt)
723
734
  };
724
735
  }
736
+ function parseArtifact(wire) {
737
+ return {
738
+ artifactId: String(wire.artifactId),
739
+ fileName: String(wire.fileName),
740
+ mimeType: String(wire.mimeType),
741
+ sizeBytes: Number(wire.sizeBytes ?? 0),
742
+ downloadUrl: wire.downloadUrl ?? null,
743
+ artifactType: wire.artifactType ?? null,
744
+ platform: wire.platform ?? null,
745
+ metadata: isRecord2(wire.metadata) ? wire.metadata : null,
746
+ isPrimary: Boolean(wire.isPrimary ?? false),
747
+ description: String(wire.description ?? "")
748
+ };
749
+ }
750
+ function parseArtifactDownload(wire) {
751
+ return {
752
+ downloadUrl: String(wire.downloadUrl),
753
+ fileName: String(wire.fileName),
754
+ sizeBytes: Number(wire.sizeBytes ?? 0),
755
+ mimeType: String(wire.mimeType),
756
+ expiresAt: toDate(wire.expiresAt)
757
+ };
758
+ }
725
759
  function parseGoalEvent(wire) {
726
760
  return {
727
761
  type: wire.type,
@@ -774,6 +808,27 @@ function parseWorkflowSearchPage(wire) {
774
808
  cursor: wire.cursor ?? null
775
809
  };
776
810
  }
811
+ function parseCatalogWorkflow(wire) {
812
+ return {
813
+ workflowId: String(wire.workflowId),
814
+ name: String(wire.name),
815
+ description: String(wire.description ?? ""),
816
+ icon: wire.icon ?? null,
817
+ supportedInputTypes: Array.isArray(wire.supportedInputTypes) ? wire.supportedInputTypes.map(String) : [],
818
+ supportedInputFormats: Array.isArray(wire.supportedInputFormats) ? wire.supportedInputFormats.map(String) : null,
819
+ category: String(wire.category ?? "goal_lane"),
820
+ requiredSlotCount: Number(wire.requiredSlotCount ?? 0),
821
+ subcategory: wire.subcategory ?? null,
822
+ skuGroup: wire.skuGroup ?? null,
823
+ status: String(wire.status ?? "active"),
824
+ supportedLocales: Array.isArray(wire.supportedLocales) ? wire.supportedLocales.map(String) : null,
825
+ maxInputSizeBytes: wire.maxInputSizeBytes ?? null,
826
+ minFileCount: wire.minFileCount ?? null,
827
+ tier: wire.tier ?? null,
828
+ // Tri-state: only an explicit false blocks a Free-tier run; absent stays null.
829
+ freeTierAllowed: wire.freeTierAllowed ?? null
830
+ };
831
+ }
777
832
  function parseLikeResponse(wire) {
778
833
  return {
779
834
  liked: Boolean(wire.liked),
@@ -859,6 +914,52 @@ var Account = class {
859
914
  }
860
915
  };
861
916
 
917
+ // src/internal/download.ts
918
+ var MAX_DOWNLOAD_BYTES = 2 * 1024 ** 3;
919
+ async function downloadUrlToPath(http, url, to, maxBytes = MAX_DOWNLOAD_BYTES) {
920
+ const { lstat, open, unlink } = await import('fs/promises');
921
+ const st = await lstat(to).catch(() => null);
922
+ if (st?.isSymbolicLink()) {
923
+ throw new Error(
924
+ `Refusing to write to ${JSON.stringify(to)}: target is a symlink. Remove it or choose a non-symlink destination.`
925
+ );
926
+ }
927
+ const response = await http.externalGet(url);
928
+ const body = response.body;
929
+ const handle = await open(to, "w");
930
+ let written = 0;
931
+ try {
932
+ if (body == null) {
933
+ const bytes = new Uint8Array(await response.arrayBuffer());
934
+ written = bytes.byteLength;
935
+ if (written > maxBytes) {
936
+ throw new Error(`Download exceeds the ${maxBytes}-byte cap`);
937
+ }
938
+ await handle.write(bytes);
939
+ } else {
940
+ const reader = body.getReader();
941
+ for (; ; ) {
942
+ const { done, value } = await reader.read();
943
+ if (done) {
944
+ break;
945
+ }
946
+ written += value.byteLength;
947
+ if (written > maxBytes) {
948
+ await reader.cancel().catch(() => void 0);
949
+ throw new Error(`Download exceeds the ${maxBytes}-byte cap`);
950
+ }
951
+ await handle.write(value);
952
+ }
953
+ }
954
+ } catch (error) {
955
+ await handle.close().catch(() => void 0);
956
+ await unlink(to).catch(() => void 0);
957
+ throw error;
958
+ }
959
+ await handle.close();
960
+ return to;
961
+ }
962
+
862
963
  // src/types.ts
863
964
  var CONVERT_JOB_TERMINAL_STATUSES = ["completed", "failed"];
864
965
  function isConvertJobTerminal(job) {
@@ -994,17 +1095,7 @@ var Convert = class {
994
1095
  /** Download the first result file to `to` (Node) and return that path. */
995
1096
  async downloadTo(job, { to }) {
996
1097
  const url = await this.downloadUrl(job);
997
- const { lstat, writeFile } = await import('fs/promises');
998
- const st = await lstat(to).catch(() => null);
999
- if (st?.isSymbolicLink()) {
1000
- throw new Error(
1001
- `Refusing to write to ${JSON.stringify(to)}: target is a symlink. Remove it or choose a non-symlink destination.`
1002
- );
1003
- }
1004
- const response = await this.#http.externalGet(url);
1005
- const bytes = new Uint8Array(await response.arrayBuffer());
1006
- await writeFile(to, bytes);
1007
- return to;
1098
+ return downloadUrlToPath(this.#http, url, to);
1008
1099
  }
1009
1100
  // ── private ────────────────────────────────────────────────────
1010
1101
  #resolveSource(options) {
@@ -1159,6 +1250,19 @@ var Files = class {
1159
1250
  const contentType = options.contentType ?? guessContentType(filename);
1160
1251
  return this.#uploadWithBody(bytes, filename, contentType);
1161
1252
  }
1253
+ /**
1254
+ * Delete an uploaded file's cloud copy (storage object + metadata record).
1255
+ *
1256
+ * Only the uploader can delete; a missing or unowned `fileId` surfaces as
1257
+ * a 404 `APIError` and a file attached to a still-running job as a 409
1258
+ * (`FILE_IN_USE`). The platform deletes input files automatically ~1 hour
1259
+ * after upload anyway — call this when you want the cloud copy gone
1260
+ * deterministically the moment your workflow is done (e.g. privacy-
1261
+ * sensitive documents on an edge device). Port of Python `files.delete`.
1262
+ */
1263
+ async delete(fileId) {
1264
+ await this.#http.request("DELETE", `/api/v1/files/${fileId}`);
1265
+ }
1162
1266
  async #uploadWithBody(body, filename, contentType) {
1163
1267
  const size = body.byteLength;
1164
1268
  const presign = await this.#presign(filename, size, contentType);
@@ -1359,7 +1463,8 @@ var Goals = class {
1359
1463
  return this.#waitLoop(
1360
1464
  jobSpecId,
1361
1465
  options.timeout ?? DEFAULT_POLL_TIMEOUT2,
1362
- options.pollInterval ?? DEFAULT_POLL_INTERVAL2
1466
+ options.pollInterval ?? DEFAULT_POLL_INTERVAL2,
1467
+ options.idleTimeout ?? null
1363
1468
  );
1364
1469
  }
1365
1470
  /** Shortcut: {@link start} then {@link wait}. */
@@ -1367,7 +1472,8 @@ var Goals = class {
1367
1472
  const job = await this.start(options);
1368
1473
  return this.wait(job.jobSpecId, {
1369
1474
  timeout: options.timeout,
1370
- pollInterval: options.pollInterval
1475
+ pollInterval: options.pollInterval,
1476
+ idleTimeout: options.idleTimeout
1371
1477
  });
1372
1478
  }
1373
1479
  /** Answer a single slot the agent is waiting on (sugar over {@link fillSlots}). */
@@ -1397,9 +1503,17 @@ var Goals = class {
1397
1503
  });
1398
1504
  return parseGoalJob(await response.json());
1399
1505
  }
1400
- /** Confirm a slots-filled job, queueing it for execution. */
1506
+ /**
1507
+ * Confirm a slots-filled job, queueing it for execution.
1508
+ *
1509
+ * `expectedVersion` is optional — the server re-reads the current version
1510
+ * at submit time regardless, so most callers should simply omit it. The
1511
+ * request always carries a JSON object (`{}` when empty): older backend
1512
+ * builds declare the confirm body as a required parameter and reject a
1513
+ * body-less POST with 422 before the handler runs.
1514
+ */
1401
1515
  async confirm(jobSpecId, options = {}) {
1402
- const json = options.expectedVersion != null ? { expectedVersion: options.expectedVersion } : void 0;
1516
+ const json = options.expectedVersion != null ? { expectedVersion: options.expectedVersion } : {};
1403
1517
  await this.#http.request("POST", `/api/v1/jobs/goal/${jobSpecId}/confirm`, { json });
1404
1518
  return this.#pollOnce(jobSpecId);
1405
1519
  }
@@ -1417,6 +1531,41 @@ var Goals = class {
1417
1531
  await this.#http.request("POST", `/api/v1/jobs/goal/${jobSpecId}/retry`, { json: body });
1418
1532
  return this.#pollOnce(jobSpecId);
1419
1533
  }
1534
+ /**
1535
+ * List a completed job's output artifacts with fresh download URLs.
1536
+ *
1537
+ * Available once the job is terminal-successful (`completed` or `partial`);
1538
+ * any other status surfaces the backend's 400 as an `APIError`. Each
1539
+ * artifact's `downloadUrl` is presigned and valid for one hour — re-call
1540
+ * this method for fresh URLs rather than persisting one.
1541
+ */
1542
+ async artifacts(jobSpecId) {
1543
+ const response = await this.#http.request("GET", `/api/v1/jobs/goal/${jobSpecId}/artifacts`);
1544
+ const payload = await response.json();
1545
+ const rows = Array.isArray(payload.artifacts) ? payload.artifacts : [];
1546
+ return rows.map((row) => parseArtifact(row));
1547
+ }
1548
+ /**
1549
+ * Mint a fresh presigned download URL for one artifact. Prefer this over
1550
+ * reusing a stale {@link Artifact.downloadUrl} when more than an hour may
1551
+ * have passed since {@link artifacts}.
1552
+ */
1553
+ async downloadArtifactUrl(jobSpecId, artifactId) {
1554
+ const response = await this.#http.request(
1555
+ "GET",
1556
+ `/api/v1/jobs/goal/${jobSpecId}/artifacts/${artifactId}/download`
1557
+ );
1558
+ return parseArtifactDownload(await response.json());
1559
+ }
1560
+ /**
1561
+ * Download one artifact to `to` (Node) and return that path. Mints a fresh
1562
+ * presigned URL first, then streams the body to disk (shares the size-cap
1563
+ * and symlink-refusal contract with `convert.downloadTo`).
1564
+ */
1565
+ async downloadArtifactTo(jobSpecId, artifactId, { to }) {
1566
+ const info = await this.downloadArtifactUrl(jobSpecId, artifactId);
1567
+ return downloadUrlToPath(this.#http, info.downloadUrl, to);
1568
+ }
1420
1569
  /**
1421
1570
  * Stream goal execution events for a job. Opens a WebSocket, subscribes,
1422
1571
  * then yields each {@link GoalEvent} in order; the iterator ends (and the
@@ -1471,11 +1620,13 @@ var Goals = class {
1471
1620
  const response = await this.#http.request("GET", `/api/v1/jobs/goal/${jobSpecId}`);
1472
1621
  return parseGoalJob(await response.json());
1473
1622
  }
1474
- async #waitLoop(jobSpecId, timeout, initialInterval) {
1623
+ async #waitLoop(jobSpecId, timeout, initialInterval, idleTimeout) {
1475
1624
  const start = this.#now() / 1e3;
1476
1625
  let interval = initialInterval;
1477
1626
  let staleCount = 0;
1478
1627
  let lastProgress = -1;
1628
+ let lastStatus = null;
1629
+ let lastChange = start;
1479
1630
  for (; ; ) {
1480
1631
  const job = await this.#pollOnce(jobSpecId);
1481
1632
  if (isGoalJobTerminal(job)) {
@@ -1484,6 +1635,10 @@ var Goals = class {
1484
1635
  if (goalJobNeedsInput(job)) {
1485
1636
  return job;
1486
1637
  }
1638
+ if (job.status !== lastStatus) {
1639
+ lastStatus = job.status;
1640
+ lastChange = this.#now() / 1e3;
1641
+ }
1487
1642
  if (job.progress === lastProgress) {
1488
1643
  staleCount += 1;
1489
1644
  if (staleCount >= STALE_PROGRESS_BACKOFF_AFTER2) {
@@ -1492,12 +1647,17 @@ var Goals = class {
1492
1647
  }
1493
1648
  } else {
1494
1649
  lastProgress = job.progress;
1650
+ lastChange = this.#now() / 1e3;
1495
1651
  staleCount = 0;
1496
1652
  }
1497
- const elapsed = this.#now() / 1e3 - start;
1653
+ const now = this.#now() / 1e3;
1654
+ const elapsed = now - start;
1498
1655
  if (elapsed + interval > timeout) {
1499
1656
  throw new GoalJobTimeoutError({ jobSpecId, elapsed, timeout });
1500
1657
  }
1658
+ if (idleTimeout != null && now - lastChange + interval > idleTimeout) {
1659
+ throw new GoalJobTimeoutError({ jobSpecId, elapsed, timeout: idleTimeout, reason: "idle" });
1660
+ }
1501
1661
  await this.#sleep(interval);
1502
1662
  }
1503
1663
  }
@@ -1545,6 +1705,20 @@ var Workflows = class {
1545
1705
  constructor(http) {
1546
1706
  this.#http = http;
1547
1707
  }
1708
+ /**
1709
+ * List the platform's built-in AI-workflow catalog.
1710
+ *
1711
+ * Distinct from {@link search}, which browses the user-published community
1712
+ * marketplace — the catalog is the curated set of built-in workflows
1713
+ * runnable directly via `goals.start({ workflowId })`. Anonymous callers
1714
+ * are allowed; deprecated entries are filtered out server-side.
1715
+ */
1716
+ async catalog() {
1717
+ const response = await this.#http.request("GET", "/api/v1/workflows/catalog");
1718
+ const payload = await response.json();
1719
+ const rows = Array.isArray(payload.workflows) ? payload.workflows : [];
1720
+ return rows.map((row) => parseCatalogWorkflow(row));
1721
+ }
1548
1722
  /** Browse the public community listing (one page + a `cursor`). */
1549
1723
  async search(options = {}) {
1550
1724
  const params = {
@@ -1615,6 +1789,9 @@ var Convilyn = class {
1615
1789
  #http;
1616
1790
  constructor(options = {}) {
1617
1791
  const auth = options.auth ?? resolveAuth(options.apiKey);
1792
+ if (options.timeout !== void 0 && options.timeout <= 0) {
1793
+ throw new TypeError(`timeout must be a positive number of seconds (got ${options.timeout})`);
1794
+ }
1618
1795
  this.#http = new HttpClient({
1619
1796
  auth,
1620
1797
  baseUrl: options.baseUrl,
@@ -1646,7 +1823,10 @@ function resolveRetryPolicy(maxRetries, retryPolicy) {
1646
1823
  if (maxRetries == null) {
1647
1824
  return new ExponentialBackoffRetry();
1648
1825
  }
1649
- if (maxRetries <= 0) {
1826
+ if (maxRetries < 0) {
1827
+ throw new TypeError(`maxRetries must be >= 0 (got ${maxRetries})`);
1828
+ }
1829
+ if (maxRetries === 0) {
1650
1830
  return new NoRetry();
1651
1831
  }
1652
1832
  return new ExponentialBackoffRetry({ maxAttempts: maxRetries });