js-bao-wss-client 2.0.6 → 2.0.8

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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/JsBaoClient.d.ts +71 -47
  3. package/dist/JsBaoClient.d.ts.map +1 -1
  4. package/dist/JsBaoClient.js +93 -10
  5. package/dist/JsBaoClient.js.map +1 -1
  6. package/dist/api/cacheFacade.d.ts +6 -6
  7. package/dist/api/cacheFacade.d.ts.map +1 -1
  8. package/dist/api/cacheFacade.js.map +1 -1
  9. package/dist/api/collectionTypeConfigsApi.d.ts +23 -0
  10. package/dist/api/collectionTypeConfigsApi.d.ts.map +1 -1
  11. package/dist/api/collectionTypeConfigsApi.js.map +1 -1
  12. package/dist/api/collectionsApi.d.ts +15 -0
  13. package/dist/api/collectionsApi.d.ts.map +1 -1
  14. package/dist/api/collectionsApi.js.map +1 -1
  15. package/dist/api/cronTriggersApi.d.ts +4 -4
  16. package/dist/api/cronTriggersApi.d.ts.map +1 -1
  17. package/dist/api/databaseTypeConfigsApi.d.ts +104 -1
  18. package/dist/api/databaseTypeConfigsApi.d.ts.map +1 -1
  19. package/dist/api/databaseTypeConfigsApi.js.map +1 -1
  20. package/dist/api/databasesApi.d.ts +63 -22
  21. package/dist/api/databasesApi.d.ts.map +1 -1
  22. package/dist/api/databasesApi.js +22 -2
  23. package/dist/api/databasesApi.js.map +1 -1
  24. package/dist/api/documentsApi.d.ts +16 -4
  25. package/dist/api/documentsApi.d.ts.map +1 -1
  26. package/dist/api/documentsApi.js +16 -0
  27. package/dist/api/documentsApi.js.map +1 -1
  28. package/dist/api/geminiApi.d.ts +9 -9
  29. package/dist/api/geminiApi.d.ts.map +1 -1
  30. package/dist/api/geminiApi.js.map +1 -1
  31. package/dist/api/invitationsApi.d.ts +15 -3
  32. package/dist/api/invitationsApi.d.ts.map +1 -1
  33. package/dist/api/invitationsApi.js +15 -3
  34. package/dist/api/invitationsApi.js.map +1 -1
  35. package/dist/api/llmApi.d.ts +8 -8
  36. package/dist/api/llmApi.d.ts.map +1 -1
  37. package/dist/api/llmApi.js.map +1 -1
  38. package/dist/api/notificationsApi.d.ts +2 -2
  39. package/dist/api/ruleSetsApi.d.ts +57 -11
  40. package/dist/api/ruleSetsApi.d.ts.map +1 -1
  41. package/dist/api/ruleSetsApi.js.map +1 -1
  42. package/dist/browser.umd.js +278 -36
  43. package/dist/browser.umd.js.map +1 -1
  44. package/dist/internal/blobManager.d.ts +5 -5
  45. package/dist/internal/blobManager.d.ts.map +1 -1
  46. package/dist/internal/blobManager.js.map +1 -1
  47. package/dist/internal/documentManager.d.ts +1 -0
  48. package/dist/internal/documentManager.d.ts.map +1 -1
  49. package/dist/internal/documentManager.js.map +1 -1
  50. package/dist/internal/httpClient.d.ts +58 -1
  51. package/dist/internal/httpClient.d.ts.map +1 -1
  52. package/dist/internal/httpClient.js +132 -21
  53. package/dist/internal/httpClient.js.map +1 -1
  54. package/package.json +1 -1
@@ -5689,10 +5689,22 @@
5689
5689
  return result.data;
5690
5690
  }
5691
5691
  async requestRaw(method, path, data, options) {
5692
- await this.refreshIfExpiring();
5693
- const response = await this.fetchWithRefresh(method, path, data, options);
5692
+ // Establish the single apply-lifecycle deadline HERE, before the proactive
5693
+ // `refreshIfExpiring()` preflight not inside `fetchWithRefresh()`. The
5694
+ // preflight can itself fire a token refresh (when the access token is
5695
+ // within 120s of expiry), and a hung refresh endpoint at that point would
5696
+ // otherwise hang the whole call before any timeout budget existed. Passing
5697
+ // the deadline into both the preflight and `fetchWithRefresh` keeps the
5698
+ // WHOLE call (proactive refresh + request + reactive refresh + retry)
5699
+ // bounded by one `timeoutMs`. Non-apply callers omit `timeoutMs`, so
5700
+ // `deadline` is undefined and every step stays untimed exactly as before.
5701
+ const timeoutMs = options?.timeoutMs;
5702
+ const deadline = typeof timeoutMs === "number" && timeoutMs > 0
5703
+ ? Date.now() + timeoutMs
5704
+ : undefined;
5705
+ await this.refreshIfExpiring(deadline);
5706
+ const { response, text } = await this.fetchWithRefresh(method, path, data, options, deadline, timeoutMs);
5694
5707
  const contentType = response.headers.get("content-type") || "";
5695
- const text = await response.text();
5696
5708
  let parsed = text;
5697
5709
  if (contentType.includes("application/json")) {
5698
5710
  if (text) {
@@ -5715,21 +5727,40 @@
5715
5727
  text,
5716
5728
  };
5717
5729
  }
5718
- async tryRefreshAccessToken() {
5730
+ /**
5731
+ * Refresh the access token via the refresh endpoint.
5732
+ *
5733
+ * `timeoutMs` bounds the refresh fetch with an `AbortController`, using the
5734
+ * same timeout path as normal requests (`fetchOrThrow`). It is passed by the
5735
+ * apply-lifecycle path so the whole call (original attempt + refresh + retry)
5736
+ * stays within the caller's request budget — a hung refresh endpoint would
5737
+ * otherwise bypass that budget and hang indefinitely. Omit it (the default)
5738
+ * to leave the refresh untimed, which is how the background
5739
+ * `refreshIfExpiring` and the public `refreshAccessToken` callers use it.
5740
+ *
5741
+ * On an aborted (timed-out) or otherwise failed refresh fetch this returns
5742
+ * `"network"`, so callers surface a retryable `JsBaoNetworkError`.
5743
+ */
5744
+ async tryRefreshAccessToken(timeoutMs) {
5719
5745
  try {
5720
5746
  const { url, headers } = this.buildRefreshRequestConfig();
5721
- const res = await fetch(url, {
5747
+ const { response: res, text } = await this.fetchOrThrow(url, {
5722
5748
  method: "POST",
5723
5749
  credentials: "include",
5724
5750
  headers,
5725
- });
5751
+ }, timeoutMs);
5726
5752
  if (res.status === 401) {
5727
5753
  return "invalid";
5728
5754
  }
5729
5755
  if (!res.ok) {
5730
5756
  return "network";
5731
5757
  }
5732
- const data = await res.json();
5758
+ // `fetchOrThrow` already read the body under the same abort timer, so a
5759
+ // refresh response that stalled mid-body was aborted (surfacing as a
5760
+ // rejected fetch → outer catch → "network") rather than hanging here.
5761
+ // A malformed body throws from JSON.parse → outer catch → "network",
5762
+ // matching the prior `res.json()` behavior.
5763
+ const data = text ? JSON.parse(text) : null;
5733
5764
  const newToken = data?.token;
5734
5765
  if (!newToken)
5735
5766
  return "invalid";
@@ -5740,14 +5771,36 @@
5740
5771
  return "network";
5741
5772
  }
5742
5773
  }
5743
- async refreshIfExpiring() {
5774
+ /**
5775
+ * Proactively refresh the access token when it is within 120s of expiry.
5776
+ *
5777
+ * When the caller opted into an apply-lifecycle deadline, thread the
5778
+ * REMAINING budget into the refresh fetch so a hung refresh endpoint at this
5779
+ * preflight point is aborted within the same `timeoutMs` (rather than hanging
5780
+ * before the request path's budget exists). This is best-effort: if the
5781
+ * budget is already spent we simply skip the proactive refresh and let the
5782
+ * request path surface the exhausted-budget network error. Untimed callers
5783
+ * (deadline undefined) keep the original behavior — an unbounded proactive
5784
+ * refresh.
5785
+ */
5786
+ async refreshIfExpiring(deadline) {
5744
5787
  try {
5745
5788
  const token = this.config.getToken();
5746
5789
  if (!token)
5747
5790
  return;
5748
5791
  const exp = this.getTokenExpiry(token);
5749
5792
  if (exp && exp - Date.now() / 1000 < 120) {
5750
- const outcome = await this.tryRefreshAccessToken();
5793
+ let budget;
5794
+ if (deadline !== undefined) {
5795
+ const remaining = deadline - Date.now();
5796
+ // No time left for a best-effort proactive refresh — skip it; the
5797
+ // request path's `remainingBudgetOrThrow` raises the retryable
5798
+ // exhausted-budget error.
5799
+ if (remaining <= 0)
5800
+ return;
5801
+ budget = remaining;
5802
+ }
5803
+ const outcome = await this.tryRefreshAccessToken(budget);
5751
5804
  this.config.onRefreshOutcome(outcome);
5752
5805
  }
5753
5806
  }
@@ -5869,16 +5922,40 @@
5869
5922
  }
5870
5923
  return { url: url.toString(), init };
5871
5924
  }
5872
- async fetchWithRefresh(method, path, data, options) {
5925
+ /**
5926
+ * Return the remaining apply-lifecycle budget for the next sub-step, or throw
5927
+ * a retryable `JsBaoNetworkError` if the shared deadline is already spent.
5928
+ * Threading the *remaining* time into each sub-step keeps every step
5929
+ * (request, token refresh, retry) bounded by the caller's single `timeoutMs`.
5930
+ * Non-timed requests (deadline undefined) return undefined — every step stays
5931
+ * untimed exactly as before.
5932
+ */
5933
+ remainingBudgetOrThrow(deadline, timeoutMs, url) {
5934
+ if (deadline === undefined)
5935
+ return undefined;
5936
+ const remaining = deadline - Date.now();
5937
+ if (remaining <= 0) {
5938
+ throw new JsBaoNetworkError(`Network request to ${url} timed out: apply-lifecycle budget of ${timeoutMs}ms exhausted`);
5939
+ }
5940
+ return remaining;
5941
+ }
5942
+ async fetchWithRefresh(method, path, data, options, deadline, timeoutMs) {
5943
+ // The whole apply-lifecycle call (original attempt + a possible token
5944
+ // refresh + the retry after refresh) shares ONE budget via the absolute
5945
+ // `deadline` established in `requestRaw`. `remainingBudgetOrThrow` throws a
5946
+ // retryable network error if the budget is already spent, so we never start
5947
+ // a sub-step (including the refresh) with no time left.
5873
5948
  let requestConfig = this.buildRequestInit(method, path, data, options);
5874
5949
  this.logger.debug(`Making ${method} request to ${requestConfig.url}`);
5875
- let response = await this.fetchOrThrow(requestConfig.url, requestConfig.init);
5876
- if (response.status === 401) {
5877
- const outcome = await this.tryRefreshAccessToken();
5950
+ let result = await this.fetchOrThrow(requestConfig.url, requestConfig.init, this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
5951
+ if (result.response.status === 401) {
5952
+ // Bound the refresh with whatever time is left in the shared budget
5953
+ // (throws if already exhausted, rather than starting an unbounded refresh).
5954
+ const outcome = await this.tryRefreshAccessToken(this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
5878
5955
  this.config.onRefreshOutcome(outcome);
5879
5956
  if (outcome === "success") {
5880
5957
  requestConfig = this.buildRequestInit(method, path, data, options);
5881
- response = await this.fetchOrThrow(requestConfig.url, requestConfig.init);
5958
+ result = await this.fetchOrThrow(requestConfig.url, requestConfig.init, this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
5882
5959
  }
5883
5960
  else if (outcome === "invalid") {
5884
5961
  throw new JsBaoApiError(401, "Invalid credentials");
@@ -5892,17 +5969,51 @@
5892
5969
  throw new JsBaoNetworkError("Token refresh failed due to a network error");
5893
5970
  }
5894
5971
  }
5895
- return response;
5972
+ return result;
5896
5973
  }
5897
- async fetchOrThrow(url, init) {
5974
+ /**
5975
+ * Fetch `url`, then read the full response body — both under one
5976
+ * `AbortController`/timeout. The timer is NOT cleared when `fetch()` resolves:
5977
+ * a resolved `fetch` only means the response HEADERS arrived. A server that
5978
+ * sends headers and then stalls the body would leave the body read
5979
+ * (`response.text()`) hanging indefinitely, stranding an apply run even though
5980
+ * the request "succeeded". Keeping the timer active through body consumption
5981
+ * lets the timeout abort a stalled body read too; on timeout the aborted read
5982
+ * rejects and we surface the same retryable `JsBaoNetworkError` as any other
5983
+ * transport failure. The timer is cleared in `finally`, after the body is
5984
+ * fully read (or the read/fetch rejects), so it never leaks.
5985
+ */
5986
+ async fetchOrThrow(url, init, timeoutMs) {
5987
+ let controller;
5988
+ let timer;
5989
+ let fetchInit = init;
5990
+ if (typeof timeoutMs === "number" &&
5991
+ timeoutMs > 0 &&
5992
+ typeof AbortController !== "undefined") {
5993
+ controller = new AbortController();
5994
+ fetchInit = { ...init, signal: controller.signal };
5995
+ timer = setTimeout(() => controller.abort(), timeoutMs);
5996
+ }
5898
5997
  try {
5899
- return await fetch(url, init);
5998
+ const response = await fetch(url, fetchInit);
5999
+ const text = await response.text();
6000
+ return { response, text };
5900
6001
  }
5901
6002
  catch (err) {
5902
- // fetch itself rejected — the server was never reached (unreachable
5903
- // host, refused connection, DNS failure, abort). Surface a typed,
5904
- // retryable network error instead of the raw TypeError.
5905
- throw new JsBaoNetworkError(`Network request to ${url} failed`, err);
6003
+ // fetch OR the body read rejected: the server was never reached
6004
+ // (unreachable host, refused connection, DNS failure), or the request /
6005
+ // body read outran its timeout and was aborted. Surface a typed,
6006
+ // retryable network error instead of the raw TypeError/AbortError so
6007
+ // callers can retry.
6008
+ const timedOut = controller?.signal.aborted === true;
6009
+ throw new JsBaoNetworkError(timedOut
6010
+ ? `Network request to ${url} timed out after ${timeoutMs}ms`
6011
+ : `Network request to ${url} failed`, err);
6012
+ }
6013
+ finally {
6014
+ if (timer) {
6015
+ clearTimeout(timer);
6016
+ }
5906
6017
  }
5907
6018
  }
5908
6019
  }
@@ -10590,6 +10701,18 @@
10590
10701
  }
10591
10702
  return this.client.makeRequest("PUT", `/documents/${documentId}/link-access`, { level });
10592
10703
  }
10704
+ /**
10705
+ * Read the current "anyone with the link" access state for a document.
10706
+ * Authorized for any caller who can read the document OR manage its sharing
10707
+ * (document owner, app owner, or read-write editor) — so an app owner can
10708
+ * inspect the state even without a content grant. Returns
10709
+ * `{ documentId, linkAccess: null }` when link access is off.
10710
+ *
10711
+ * @param documentId - The document to read link access for.
10712
+ */
10713
+ async getLinkAccess(documentId) {
10714
+ return this.client.makeRequest("GET", `/documents/${documentId}/link-access`);
10715
+ }
10593
10716
  /**
10594
10717
  * Turn off "anyone with the link" access for a document. Existing explicit
10595
10718
  * grants are unaffected; link-only viewers lose access.
@@ -11314,6 +11437,10 @@
11314
11437
  async setLinkAccess(level) {
11315
11438
  return this.docs.setLinkAccess(this.documentId, level);
11316
11439
  }
11440
+ /** Read the current "anyone with the link" access state for this document. */
11441
+ async getLinkAccess() {
11442
+ return this.docs.getLinkAccess(this.documentId);
11443
+ }
11317
11444
  /** Turn off "anyone with the link" access for this document. */
11318
11445
  async clearLinkAccess() {
11319
11446
  return this.docs.clearLinkAccess(this.documentId);
@@ -12384,6 +12511,11 @@
12384
12511
  * The returned payload includes the same dict under both `metadata` (legacy
12385
12512
  * wire name) and `celContext` (new user-facing name).
12386
12513
  *
12514
+ * @deprecated Prefer resource metadata categories (issue #1420). The
12515
+ * `metadataAccess` gate that controls this read uses one CEL expression to
12516
+ * gate read AND update, so granting read also grants update. A metadata
12517
+ * category has separate `readRule`/`writeRule`; read its values from CEL as
12518
+ * `md.self.<category>.<key>`. See the migration recipe in `databases.md`.
12387
12519
  * @param databaseId - The unique identifier of the database to read
12388
12520
  */
12389
12521
  async getCelContext(databaseId) {
@@ -12396,6 +12528,12 @@
12396
12528
  * `database.celContext.<key>` (or legacy `database.metadata.<key>`) and
12397
12529
  * from filter JSON as `$database.celContext.<key>`.
12398
12530
  *
12531
+ * @deprecated Prefer resource metadata categories (issue #1420). Writing here
12532
+ * is gated by the same single `metadataAccess` expression that gates reads, so
12533
+ * anyone allowed to read can also update. A metadata category has separate
12534
+ * `readRule`/`writeRule`; write its values with `resourceMetadata` /
12535
+ * `initialMetadata` and read them from CEL as `md.self.<category>.<key>`. See
12536
+ * the migration recipe in `databases.md`.
12399
12537
  * @param databaseId - The unique identifier of the database to update
12400
12538
  * @param celContext - Key-value pairs to merge into the database's existing CEL context
12401
12539
  */
@@ -12404,7 +12542,11 @@
12404
12542
  }
12405
12543
  /**
12406
12544
  * Read a database's CEL context dict.
12407
- * @deprecated Use {@link getCelContext} instead.
12545
+ * @deprecated Prefer resource metadata categories (issue #1420) — a category
12546
+ * has separate `readRule`/`writeRule` (read no longer implies update) and is
12547
+ * read from CEL as `md.self.<category>.<key>`. See the migration recipe in
12548
+ * `databases.md`. This is the legacy wire-name alias of the (also-deprecated)
12549
+ * {@link getCelContext}.
12408
12550
  * @param databaseId - The unique identifier of the database to read
12409
12551
  */
12410
12552
  async getMetadata(databaseId) {
@@ -12412,7 +12554,12 @@
12412
12554
  }
12413
12555
  /**
12414
12556
  * Update a database's CEL context dict (merge with existing).
12415
- * @deprecated Use {@link updateCelContext} instead.
12557
+ * @deprecated Prefer resource metadata categories (issue #1420) — a category
12558
+ * has separate `readRule`/`writeRule`, so a writer no longer inherits update
12559
+ * from read access. Write category values with `resourceMetadata` /
12560
+ * `initialMetadata` and read them from CEL as `md.self.<category>.<key>`. See
12561
+ * the migration recipe in `databases.md`. This is the legacy wire-name alias
12562
+ * of the (also-deprecated) {@link updateCelContext}.
12416
12563
  * @param databaseId - The unique identifier of the database to update
12417
12564
  * @param metadata - Key-value pairs to merge into the database's existing CEL context
12418
12565
  */
@@ -13541,7 +13688,14 @@
13541
13688
  return this.client.makeRequest("POST", "/invitations", params);
13542
13689
  }
13543
13690
  /**
13544
- * List app-level invitations (admin/owner only).
13691
+ * List app-level invitations.
13692
+ *
13693
+ * Admins and owners see the full app list. Members see only the
13694
+ * invitations they created themselves — the `memberInvitationsEnabled`
13695
+ * flag gates *creating* invitations, not listing your own (a member can
13696
+ * always see and revoke invitations they already sent). A member with no
13697
+ * invitations of their own gets an empty list, not a 403.
13698
+ *
13545
13699
  * @param options - Pagination options
13546
13700
  */
13547
13701
  async list(options) {
@@ -13555,8 +13709,13 @@
13555
13709
  return this.client.makeRequest("GET", path);
13556
13710
  }
13557
13711
  /**
13558
- * Delete an app-level invitation (admin/owner only).
13559
- * Also cascade-deletes any linked deferred grants.
13712
+ * Delete (revoke) an app-level invitation.
13713
+ *
13714
+ * Admins and owners can revoke any invitation. A member can revoke only
13715
+ * invitations they created themselves (403 otherwise). Revoking an active
13716
+ * invitation frees the member's quota. Also cascade-deletes any linked
13717
+ * deferred grants.
13718
+ *
13560
13719
  * @param invitationId - The invitation to delete
13561
13720
  */
13562
13721
  async delete(invitationId) {
@@ -13687,6 +13846,24 @@
13687
13846
  const DEFAULT_RETURN_ACTIVE_MIN_MS = 5 * 60 * 1000;
13688
13847
  const DEFAULT_SYNC_ERROR_MIN_MS = 30 * 1000;
13689
13848
  const logger = createLogger({ scope: "JsBaoClient" });
13849
+ // Workflow apply-lifecycle durability (#1694). The server confirm is idempotent
13850
+ // (read-then-conditional-write; returns `confirmed: true` if the run already
13851
+ // completed), so a lost or hung confirm POST is safe to retry — retrying only
13852
+ // re-sends the confirm, never the user's onApply handler. These bounds keep the
13853
+ // retry inside the server's 30s apply lease so a stranded run recovers within
13854
+ // the same client session instead of waiting for a full restart.
13855
+ //
13856
+ // A per-request timeout (via AbortController in the HTTP layer) turns a hung
13857
+ // apply POST into a retryable network error. Scope note: this timeout is applied
13858
+ // only to the apply-lifecycle calls (claim/confirm/release), not to every client
13859
+ // request — a blanket request timeout has a much larger blast radius.
13860
+ const APPLY_REQUEST_TIMEOUT_MS = 8000;
13861
+ const APPLY_CONFIRM_MAX_ATTEMPTS = 6;
13862
+ const APPLY_CONFIRM_BASE_DELAY_MS = 250;
13863
+ const APPLY_CONFIRM_MAX_DELAY_MS = 4000;
13864
+ // Total retry budget, kept under the 30s server apply lease so the claim is
13865
+ // still valid when a retry finally lands.
13866
+ const APPLY_CONFIRM_RETRY_BUDGET_MS = 25000;
13690
13867
  function debugLog(tag, payload) {
13691
13868
  try {
13692
13869
  if (!logger.shouldLog("debug"))
@@ -16268,7 +16445,9 @@
16268
16445
  if (options.contextDocId) {
16269
16446
  payload.contextDocId = options.contextDocId;
16270
16447
  }
16271
- const response = await this.makeRequest("POST", path, payload);
16448
+ const response = await this.makeRequest("POST", path, payload, {
16449
+ timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
16450
+ });
16272
16451
  return response;
16273
16452
  }
16274
16453
  async releaseWorkflowApply(options) {
@@ -16279,7 +16458,9 @@
16279
16458
  if (options.contextDocId) {
16280
16459
  payload.contextDocId = options.contextDocId;
16281
16460
  }
16282
- const response = await this.makeRequest("POST", path, payload);
16461
+ const response = await this.makeRequest("POST", path, payload, {
16462
+ timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
16463
+ });
16283
16464
  return response;
16284
16465
  }
16285
16466
  async confirmWorkflowApply(options) {
@@ -16296,9 +16477,63 @@
16296
16477
  if (options.contextDocId) {
16297
16478
  payload.contextDocId = options.contextDocId;
16298
16479
  }
16299
- const response = await this.makeRequest("POST", path, payload);
16480
+ const response = await this.makeRequest("POST", path, payload, {
16481
+ timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
16482
+ });
16300
16483
  return response;
16301
16484
  }
16485
+ /**
16486
+ * Whether a thrown confirm-apply error is worth retrying. Network failures
16487
+ * (unreachable server, refused connection, DNS failure, or an aborted
16488
+ * timeout — all surfaced as `JsBaoNetworkError`) and HTTP 5xx are transient,
16489
+ * so retrying the idempotent confirm may still succeed. Any other HTTP status
16490
+ * (4xx) is a definitive rejection and is not retried.
16491
+ */
16492
+ isRetryableConfirmError(err) {
16493
+ if (err instanceof JsBaoNetworkError) {
16494
+ return true;
16495
+ }
16496
+ if (err instanceof JsBaoApiError) {
16497
+ return err.status >= 500 && err.status < 600;
16498
+ }
16499
+ return false;
16500
+ }
16501
+ /**
16502
+ * Retry the confirm POST on transient failures, bounded by both an attempt
16503
+ * count and a time budget that stays within the server's apply lease. Only
16504
+ * the confirm POST is retried here — the caller runs the user's onApply
16505
+ * handler exactly once, before invoking this, so retries never re-run it.
16506
+ * A `confirmed: false` result (a genuine claim loss) is returned normally,
16507
+ * not retried; the caller releases the claim in that case.
16508
+ */
16509
+ async confirmWorkflowApplyWithRetry(options) {
16510
+ const deadline = Date.now() + APPLY_CONFIRM_RETRY_BUDGET_MS;
16511
+ let attempt = 0;
16512
+ let delayMs = APPLY_CONFIRM_BASE_DELAY_MS;
16513
+ for (;;) {
16514
+ attempt++;
16515
+ try {
16516
+ return await this.confirmWorkflowApply(options);
16517
+ }
16518
+ catch (err) {
16519
+ const nextDelay = Math.min(delayMs, APPLY_CONFIRM_MAX_DELAY_MS);
16520
+ const outOfBudget = Date.now() + nextDelay >= deadline;
16521
+ if (!this.isRetryableConfirmError(err) ||
16522
+ attempt >= APPLY_CONFIRM_MAX_ATTEMPTS ||
16523
+ outOfBudget) {
16524
+ throw err;
16525
+ }
16526
+ logger.warn("[workflowApply] confirm failed, retrying", {
16527
+ workflowKey: options.workflowKey,
16528
+ runKey: options.runKey,
16529
+ attempt,
16530
+ error: err instanceof Error ? err.message : String(err),
16531
+ });
16532
+ await new Promise((resolve) => setTimeout(resolve, nextDelay));
16533
+ delayMs = Math.min(delayMs * 2, APPLY_CONFIRM_MAX_DELAY_MS);
16534
+ }
16535
+ }
16536
+ }
16302
16537
  async getWorkflowPendingApplies(options) {
16303
16538
  if (!options?.contextDocId) {
16304
16539
  throw new JsBaoError("INVALID_ARGUMENT", "contextDocId is required for workflows.getPendingApplies");
@@ -16346,11 +16581,15 @@
16346
16581
  startedByUserId: event.startedByUserId,
16347
16582
  meta: statusResult.run?.meta ?? undefined,
16348
16583
  });
16349
- // Confirm the apply. Surface a server-side rejection (`confirmed: false`)
16350
- // as an error so we don't silently leave the workflow stuck in
16351
- // `apply_claimed` (issue #614). The catch block below releases the
16352
- // claim and emits a diagnostic.
16353
- const confirmResult = await this.confirmWorkflowApply({
16584
+ // Confirm the apply, retrying the POST on transient failures (#1694).
16585
+ // The handler above has already run exactly once; the retry re-sends only
16586
+ // the idempotent confirm, never the handler. A lost or hung confirm would
16587
+ // otherwise strand the run in `apply_claimed` for the whole client session
16588
+ // with no way to recover short of a restart. Surface a server-side
16589
+ // rejection (`confirmed: false`) as an error so we don't silently leave
16590
+ // the workflow stuck in `apply_claimed` (issue #614). The catch block
16591
+ // below releases the claim and emits a diagnostic.
16592
+ const confirmResult = await this.confirmWorkflowApplyWithRetry({
16354
16593
  workflowKey: event.workflowKey,
16355
16594
  runKey: event.runKey,
16356
16595
  contextDocId: event.contextDocId,
@@ -16360,7 +16599,10 @@
16360
16599
  }
16361
16600
  }
16362
16601
  catch (err) {
16363
- logger.warn("[workflowApply] apply handler failed", {
16602
+ // Elevated to `error` (#1694): apply-flow failures strand a run in
16603
+ // `apply_claimed`, and tests (and apps) commonly run at logLevel `error`,
16604
+ // so a `warn`/`debug` diagnostic here is invisible exactly when it matters.
16605
+ logger.error("[workflowApply] apply handler failed", {
16364
16606
  workflowKey: event.workflowKey,
16365
16607
  runKey: event.runKey,
16366
16608
  error: err instanceof Error ? err.message : String(err),
@@ -16374,7 +16616,7 @@
16374
16616
  });
16375
16617
  }
16376
16618
  catch (releaseErr) {
16377
- logger.debug("[workflowApply] failed to release claim", {
16619
+ logger.error("[workflowApply] failed to release claim", {
16378
16620
  workflowKey: event.workflowKey,
16379
16621
  runKey: event.runKey,
16380
16622
  error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),