@convilyn/sdk 0.3.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,7 +6,7 @@ var fs = require('fs');
6
6
  var path = require('path');
7
7
 
8
8
  // src/version.ts
9
- var VERSION = "0.3.0";
9
+ var VERSION = "0.4.0";
10
10
 
11
11
  // src/errors.ts
12
12
  var CONVILYN_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@convilyn/sdk.ConvilynError");
@@ -118,14 +118,22 @@ var GoalJobTimeoutError = class extends ConvilynError {
118
118
  jobSpecId;
119
119
  elapsed;
120
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;
121
127
  constructor(opts) {
128
+ const reason = opts.reason ?? "total";
122
129
  super(
123
- `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)`
124
131
  );
125
132
  this.name = "GoalJobTimeoutError";
126
133
  this.jobSpecId = opts.jobSpecId;
127
134
  this.elapsed = opts.elapsed;
128
135
  this.timeout = opts.timeout;
136
+ this.reason = reason;
129
137
  }
130
138
  };
131
139
  var WebSocketError = class extends ConvilynError {
@@ -725,6 +733,29 @@ function parseGoalJob(wire) {
725
733
  completedAt: toDateOrNull(wire.completedAt)
726
734
  };
727
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
+ }
728
759
  function parseGoalEvent(wire) {
729
760
  return {
730
761
  type: wire.type,
@@ -777,6 +808,27 @@ function parseWorkflowSearchPage(wire) {
777
808
  cursor: wire.cursor ?? null
778
809
  };
779
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
+ }
780
832
  function parseLikeResponse(wire) {
781
833
  return {
782
834
  liked: Boolean(wire.liked),
@@ -862,6 +914,52 @@ var Account = class {
862
914
  }
863
915
  };
864
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
+
865
963
  // src/types.ts
866
964
  var CONVERT_JOB_TERMINAL_STATUSES = ["completed", "failed"];
867
965
  function isConvertJobTerminal(job) {
@@ -997,17 +1095,7 @@ var Convert = class {
997
1095
  /** Download the first result file to `to` (Node) and return that path. */
998
1096
  async downloadTo(job, { to }) {
999
1097
  const url = await this.downloadUrl(job);
1000
- const { lstat, writeFile } = await import('fs/promises');
1001
- const st = await lstat(to).catch(() => null);
1002
- if (st?.isSymbolicLink()) {
1003
- throw new Error(
1004
- `Refusing to write to ${JSON.stringify(to)}: target is a symlink. Remove it or choose a non-symlink destination.`
1005
- );
1006
- }
1007
- const response = await this.#http.externalGet(url);
1008
- const bytes = new Uint8Array(await response.arrayBuffer());
1009
- await writeFile(to, bytes);
1010
- return to;
1098
+ return downloadUrlToPath(this.#http, url, to);
1011
1099
  }
1012
1100
  // ── private ────────────────────────────────────────────────────
1013
1101
  #resolveSource(options) {
@@ -1162,6 +1250,19 @@ var Files = class {
1162
1250
  const contentType = options.contentType ?? guessContentType(filename);
1163
1251
  return this.#uploadWithBody(bytes, filename, contentType);
1164
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
+ }
1165
1266
  async #uploadWithBody(body, filename, contentType) {
1166
1267
  const size = body.byteLength;
1167
1268
  const presign = await this.#presign(filename, size, contentType);
@@ -1362,7 +1463,8 @@ var Goals = class {
1362
1463
  return this.#waitLoop(
1363
1464
  jobSpecId,
1364
1465
  options.timeout ?? DEFAULT_POLL_TIMEOUT2,
1365
- options.pollInterval ?? DEFAULT_POLL_INTERVAL2
1466
+ options.pollInterval ?? DEFAULT_POLL_INTERVAL2,
1467
+ options.idleTimeout ?? null
1366
1468
  );
1367
1469
  }
1368
1470
  /** Shortcut: {@link start} then {@link wait}. */
@@ -1370,7 +1472,8 @@ var Goals = class {
1370
1472
  const job = await this.start(options);
1371
1473
  return this.wait(job.jobSpecId, {
1372
1474
  timeout: options.timeout,
1373
- pollInterval: options.pollInterval
1475
+ pollInterval: options.pollInterval,
1476
+ idleTimeout: options.idleTimeout
1374
1477
  });
1375
1478
  }
1376
1479
  /** Answer a single slot the agent is waiting on (sugar over {@link fillSlots}). */
@@ -1400,9 +1503,17 @@ var Goals = class {
1400
1503
  });
1401
1504
  return parseGoalJob(await response.json());
1402
1505
  }
1403
- /** 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
+ */
1404
1515
  async confirm(jobSpecId, options = {}) {
1405
- const json = options.expectedVersion != null ? { expectedVersion: options.expectedVersion } : void 0;
1516
+ const json = options.expectedVersion != null ? { expectedVersion: options.expectedVersion } : {};
1406
1517
  await this.#http.request("POST", `/api/v1/jobs/goal/${jobSpecId}/confirm`, { json });
1407
1518
  return this.#pollOnce(jobSpecId);
1408
1519
  }
@@ -1420,6 +1531,41 @@ var Goals = class {
1420
1531
  await this.#http.request("POST", `/api/v1/jobs/goal/${jobSpecId}/retry`, { json: body });
1421
1532
  return this.#pollOnce(jobSpecId);
1422
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
+ }
1423
1569
  /**
1424
1570
  * Stream goal execution events for a job. Opens a WebSocket, subscribes,
1425
1571
  * then yields each {@link GoalEvent} in order; the iterator ends (and the
@@ -1474,11 +1620,13 @@ var Goals = class {
1474
1620
  const response = await this.#http.request("GET", `/api/v1/jobs/goal/${jobSpecId}`);
1475
1621
  return parseGoalJob(await response.json());
1476
1622
  }
1477
- async #waitLoop(jobSpecId, timeout, initialInterval) {
1623
+ async #waitLoop(jobSpecId, timeout, initialInterval, idleTimeout) {
1478
1624
  const start = this.#now() / 1e3;
1479
1625
  let interval = initialInterval;
1480
1626
  let staleCount = 0;
1481
1627
  let lastProgress = -1;
1628
+ let lastStatus = null;
1629
+ let lastChange = start;
1482
1630
  for (; ; ) {
1483
1631
  const job = await this.#pollOnce(jobSpecId);
1484
1632
  if (isGoalJobTerminal(job)) {
@@ -1487,6 +1635,10 @@ var Goals = class {
1487
1635
  if (goalJobNeedsInput(job)) {
1488
1636
  return job;
1489
1637
  }
1638
+ if (job.status !== lastStatus) {
1639
+ lastStatus = job.status;
1640
+ lastChange = this.#now() / 1e3;
1641
+ }
1490
1642
  if (job.progress === lastProgress) {
1491
1643
  staleCount += 1;
1492
1644
  if (staleCount >= STALE_PROGRESS_BACKOFF_AFTER2) {
@@ -1495,12 +1647,17 @@ var Goals = class {
1495
1647
  }
1496
1648
  } else {
1497
1649
  lastProgress = job.progress;
1650
+ lastChange = this.#now() / 1e3;
1498
1651
  staleCount = 0;
1499
1652
  }
1500
- const elapsed = this.#now() / 1e3 - start;
1653
+ const now = this.#now() / 1e3;
1654
+ const elapsed = now - start;
1501
1655
  if (elapsed + interval > timeout) {
1502
1656
  throw new GoalJobTimeoutError({ jobSpecId, elapsed, timeout });
1503
1657
  }
1658
+ if (idleTimeout != null && now - lastChange + interval > idleTimeout) {
1659
+ throw new GoalJobTimeoutError({ jobSpecId, elapsed, timeout: idleTimeout, reason: "idle" });
1660
+ }
1504
1661
  await this.#sleep(interval);
1505
1662
  }
1506
1663
  }
@@ -1548,6 +1705,20 @@ var Workflows = class {
1548
1705
  constructor(http) {
1549
1706
  this.#http = http;
1550
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
+ }
1551
1722
  /** Browse the public community listing (one page + a `cursor`). */
1552
1723
  async search(options = {}) {
1553
1724
  const params = {