@impulselab/hepha 0.2.280 → 0.2.282

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 CHANGED
@@ -37,7 +37,10 @@ hepha sessions list --json
37
37
  ```
38
38
 
39
39
  `HEPHA_API_KEY` takes precedence over a stored login. `HEPHA_URL` can point the
40
- CLI at another Hephaistos deployment.
40
+ CLI at another Hephaistos deployment. `HEPHA_TIMEOUT_MS` bounds one request;
41
+ `HEPHA_FOLLOW_MAX_OUTAGE_SECONDS` makes `--follow` give up that long after its
42
+ first failure, exiting with that failure — unset, it waits for the platform to
43
+ come back.
41
44
 
42
45
  ## Exit codes
43
46
 
package/dist/index.js CHANGED
@@ -122,6 +122,11 @@ var CliOutput = class {
122
122
  }
123
123
  error(text) {
124
124
  process.stderr.write(`${this.paint(`error: ${this.safe(text)}`, "red")}
125
+ `);
126
+ }
127
+ // Progress a long command owes the user; stderr keeps `--json` stdout whole.
128
+ warn(text) {
129
+ process.stderr.write(`${this.paint(this.safe(text), "yellow")}
125
130
  `);
126
131
  }
127
132
  /**
@@ -387,6 +392,10 @@ ENVIRONMENT
387
392
  HEPHA_TOKEN A device-flow session token, instead of ~/.hepha/config.json.
388
393
  HEPHA_URL Platform base URL.
389
394
  HEPHA_TIMEOUT_MS Per-request timeout in milliseconds (default 30000).
395
+ HEPHA_FOLLOW_MAX_OUTAGE_SECONDS
396
+ Give up this long after --follow's first failure, with that
397
+ failure as the exit code. Unset, it waits for the platform
398
+ to come back.
390
399
 
391
400
  GLOBAL FLAGS
392
401
  --json Machine-readable output on stdout. Every command that
@@ -632,7 +641,11 @@ function retryAfterSeconds(response) {
632
641
  return void 0;
633
642
  }
634
643
  const seconds = Number(raw.trim());
635
- return Number.isFinite(seconds) && seconds >= 0 ? seconds : void 0;
644
+ if (Number.isFinite(seconds)) {
645
+ return seconds >= 0 ? seconds : void 0;
646
+ }
647
+ const date = Date.parse(raw.trim());
648
+ return Number.isNaN(date) ? void 0 : Math.max(0, (date - Date.now()) / 1e3);
636
649
  }
637
650
 
638
651
  // src/lib/response/fetch-response.ts
@@ -841,6 +854,8 @@ function expectShape(payload, endpoint, requirements) {
841
854
  // src/lib/is/is.ts
842
855
  var is = {
843
856
  string: (value) => typeof value === "string",
857
+ // Absent passes: a field a deployment older than this binary does not send.
858
+ optionalString: (value) => value === void 0 || typeof value === "string",
844
859
  number: (value) => typeof value === "number",
845
860
  array: (value) => Array.isArray(value),
846
861
  object: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value)
@@ -1180,7 +1195,7 @@ var deviceTokenError = {
1180
1195
  }
1181
1196
  };
1182
1197
 
1183
- // src/commands/next/next-poll-delay.ts
1198
+ // src/lib/retry/next-poll-delay.ts
1184
1199
  function nextPollDelay(interval, remainingSeconds) {
1185
1200
  if (remainingSeconds <= 0) {
1186
1201
  return null;
@@ -1739,28 +1754,224 @@ function consumeSessionEvents(events, cursor, quiet) {
1739
1754
  return cursor;
1740
1755
  }
1741
1756
 
1742
- // src/lib/terminal/terminal-constants.ts
1743
- var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "killed", "abstained"]);
1757
+ // src/lib/sleep/sleep.ts
1758
+ function sleep2(seconds) {
1759
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
1760
+ }
1761
+
1762
+ // src/lib/sleep/sleep-constants.ts
1763
+ var MAX_TIMER_MS = 2147483647;
1764
+
1765
+ // src/lib/sleep/timer-delay-ms.ts
1766
+ function timerDelayMs(seconds) {
1767
+ return Math.min(Math.max(0, seconds) * 1e3, MAX_TIMER_MS);
1768
+ }
1769
+
1770
+ // src/lib/sleep/interruptible-sleep.ts
1771
+ function interruptibleSleep(seconds) {
1772
+ return new Promise((resolve, reject) => {
1773
+ const onInterrupt = () => {
1774
+ clearTimeout(timer);
1775
+ reject(
1776
+ new CliError("Follow interrupted.", 130, {
1777
+ code: CLI_ERROR_CODES.INTERRUPTED
1778
+ })
1779
+ );
1780
+ };
1781
+ const timer = setTimeout(() => {
1782
+ process.removeListener("SIGINT", onInterrupt);
1783
+ resolve();
1784
+ }, timerDelayMs(seconds));
1785
+ process.once("SIGINT", onInterrupt);
1786
+ });
1787
+ }
1788
+
1789
+ // src/lib/session/default-follow-session-dependencies.ts
1790
+ var defaultFollowSessionDependencies = {
1791
+ request: api,
1792
+ pollSleep: sleep2,
1793
+ retrySleep: interruptibleSleep,
1794
+ random: Math.random,
1795
+ // Monotonic: an NTP step backwards must not hand the outage budget more time
1796
+ // to burn, nor mute the periodic reminder until the clock catches up.
1797
+ now: () => performance.now(),
1798
+ warn: (message) => out.warn(message)
1799
+ };
1800
+
1801
+ // src/lib/session/retry/follow-outage-message.ts
1802
+ function followOutageMessage(warned) {
1803
+ return warned ? "Hepha API still unavailable; retrying follow." : "Hepha API unavailable; retrying follow.";
1804
+ }
1805
+
1806
+ // src/lib/session/retry/follow-retry-constants.ts
1807
+ var FOLLOW_RETRY_CAP_SECONDS = 22.5;
1808
+ var FOLLOW_OUTAGE_FEEDBACK_MS = 5 * 60 * 1e3;
1809
+ var RETRYABLE_FOLLOW_STATUS = /* @__PURE__ */ new Set([
1810
+ 408,
1811
+ 425,
1812
+ 429,
1813
+ 500,
1814
+ 502,
1815
+ 503,
1816
+ 504
1817
+ ]);
1818
+
1819
+ // src/lib/session/retry/follow-retry-delay.ts
1820
+ function followRetryDelay(error, failures, random) {
1821
+ const exponential = Math.min(
1822
+ FOLLOW_RETRY_CAP_SECONDS,
1823
+ 2 ** Math.min(failures - 1, 30)
1824
+ );
1825
+ const jittered = exponential / 2 + random() * (exponential / 2);
1826
+ const retryAfter = Math.max(0, error.details.retryAfterSeconds ?? 0);
1827
+ return Math.max(jittered, retryAfter + random() * (exponential / 2));
1828
+ }
1829
+
1830
+ // src/lib/session/retry/remaining-outage-seconds.ts
1831
+ function remainingOutageSeconds(outageStartedAt, now, maxOutageMs) {
1832
+ if (maxOutageMs === null) {
1833
+ return Number.POSITIVE_INFINITY;
1834
+ }
1835
+ return (maxOutageMs - (now - outageStartedAt)) / 1e3;
1836
+ }
1837
+
1838
+ // src/lib/session/retry/retryable-follow-error.ts
1839
+ function retryableFollowError(error) {
1840
+ return error instanceof CliError && (RETRYABLE_FOLLOW_STATUS.has(error.details.status ?? 0) || error.details.code === CLI_ERROR_CODES.NETWORK || error.details.code === CLI_ERROR_CODES.TIMEOUT);
1841
+ }
1842
+
1843
+ // src/lib/session/retry/should-report-follow-outage.ts
1844
+ function shouldReportFollowOutage(lastWarnedAt, now) {
1845
+ return lastWarnedAt === null || now - lastWarnedAt >= FOLLOW_OUTAGE_FEEDBACK_MS;
1846
+ }
1847
+
1848
+ // src/lib/session/retry/wait-for-follow-retry.ts
1849
+ async function waitForFollowRetry(params) {
1850
+ const { error, retry, dependencies } = params;
1851
+ if (!retryableFollowError(error)) {
1852
+ throw error;
1853
+ }
1854
+ retry.failures += 1;
1855
+ const now = dependencies.now();
1856
+ retry.outageStartedAt ??= now;
1857
+ const delay = nextPollDelay(
1858
+ followRetryDelay(error, retry.failures, dependencies.random),
1859
+ remainingOutageSeconds(retry.outageStartedAt, now, params.maxOutageMs)
1860
+ );
1861
+ if (delay === null) {
1862
+ throw error;
1863
+ }
1864
+ if (shouldReportFollowOutage(retry.lastWarnedAt, now)) {
1865
+ dependencies.warn(followOutageMessage(retry.warned));
1866
+ retry.warned = true;
1867
+ retry.lastWarnedAt = now;
1868
+ }
1869
+ await dependencies.retrySleep(delay);
1870
+ if (remainingOutageSeconds(
1871
+ retry.outageStartedAt,
1872
+ dependencies.now(),
1873
+ params.maxOutageMs
1874
+ ) <= 0) {
1875
+ throw error;
1876
+ }
1877
+ }
1878
+
1879
+ // src/lib/session/retry/request-with-follow-retry.ts
1880
+ async function requestWithFollowRetry(params) {
1881
+ for (; ; ) {
1882
+ try {
1883
+ const result = await params.operation();
1884
+ if (params.retry.warned) {
1885
+ params.dependencies.warn("Hepha API recovered; continuing follow.");
1886
+ }
1887
+ params.retry.failures = 0;
1888
+ params.retry.warned = false;
1889
+ params.retry.lastWarnedAt = null;
1890
+ params.retry.outageStartedAt = null;
1891
+ return result;
1892
+ } catch (error) {
1893
+ await waitForFollowRetry({
1894
+ error,
1895
+ retry: params.retry,
1896
+ maxOutageMs: params.maxOutageMs,
1897
+ dependencies: params.dependencies
1898
+ });
1899
+ }
1900
+ }
1901
+ }
1744
1902
 
1745
1903
  // src/lib/blocked/blocked-constants.ts
1746
1904
  var BLOCKED = /* @__PURE__ */ new Set(["waiting_human", "awaiting_approval"]);
1747
1905
 
1748
- // src/lib/sleep/sleep.ts
1749
- function sleep2(seconds) {
1750
- return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
1906
+ // src/lib/terminal/terminal-constants.ts
1907
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "killed", "abstained"]);
1908
+
1909
+ // src/lib/session/is-follow-stop-status.ts
1910
+ function isFollowStopStatus(status) {
1911
+ return status !== void 0 && (TERMINAL.has(status) || BLOCKED.has(status));
1912
+ }
1913
+
1914
+ // src/lib/session/retry/resolve-follow-outage-budget.ts
1915
+ function resolveFollowOutageBudget() {
1916
+ const raw = process.env.HEPHA_FOLLOW_MAX_OUTAGE_SECONDS?.trim();
1917
+ if (!raw) {
1918
+ return null;
1919
+ }
1920
+ const budgetMs = Number(raw) * 1e3;
1921
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0) {
1922
+ throw new CliError(
1923
+ `HEPHA_FOLLOW_MAX_OUTAGE_SECONDS expects a positive number of seconds, got "${raw}".`,
1924
+ 2
1925
+ );
1926
+ }
1927
+ return budgetMs;
1928
+ }
1929
+
1930
+ // src/lib/session/retry/follow-request-timeout-ms.ts
1931
+ function followRequestTimeoutMs(retry, maxOutageMs, now) {
1932
+ if (retry.outageStartedAt === null) {
1933
+ return resolveTimeout();
1934
+ }
1935
+ const remainingMs = remainingOutageSeconds(retry.outageStartedAt, now, maxOutageMs) * 1e3;
1936
+ return Math.max(1, Math.min(resolveTimeout(), remainingMs));
1751
1937
  }
1752
1938
 
1753
1939
  // src/lib/session/follow-session.ts
1754
- async function followSession(sessionId, options = {}) {
1940
+ async function followSession(sessionId, options = {}, dependencies = defaultFollowSessionDependencies) {
1755
1941
  let cursor = options.after ?? 0;
1942
+ let emptyPolls = 0;
1943
+ const retry = {
1944
+ failures: 0,
1945
+ warned: false,
1946
+ lastWarnedAt: null,
1947
+ outageStartedAt: null
1948
+ };
1949
+ const maxOutageMs = resolveFollowOutageBudget();
1756
1950
  for (; ; ) {
1757
- const page = expectShape(
1758
- await api(path`/sessions/${sessionId}/events`, {
1759
- query: { after: cursor || void 0, limit: PAGE_SIZE }
1760
- }),
1761
- "GET /sessions/{id}/events",
1762
- { items: is.array }
1763
- );
1951
+ const page = await requestWithFollowRetry({
1952
+ retry,
1953
+ maxOutageMs,
1954
+ dependencies,
1955
+ operation: async () => expectShape(
1956
+ await dependencies.request(path`/sessions/${sessionId}/events`, {
1957
+ query: { after: cursor || void 0, limit: PAGE_SIZE },
1958
+ timeoutMs: followRequestTimeoutMs(
1959
+ retry,
1960
+ maxOutageMs,
1961
+ dependencies.now()
1962
+ )
1963
+ }),
1964
+ "GET /sessions/{id}/events",
1965
+ {
1966
+ items: is.array,
1967
+ hasMore: (value) => typeof value === "boolean",
1968
+ nextCursor: (value) => value === null || is.number(value),
1969
+ // Optional: a deployment older than this binary omits it, and a
1970
+ // missing field here would be a non-retryable schema mismatch.
1971
+ status: is.optionalString
1972
+ }
1973
+ )
1974
+ });
1764
1975
  cursor = consumeSessionEvents(page.items, cursor, options.quiet);
1765
1976
  if (page.nextCursor !== null) {
1766
1977
  cursor = Math.max(cursor, page.nextCursor);
@@ -1768,11 +1979,30 @@ async function followSession(sessionId, options = {}) {
1768
1979
  if (page.hasMore) {
1769
1980
  continue;
1770
1981
  }
1771
- const session = await api(path`/sessions/${sessionId}`);
1772
- if (TERMINAL.has(session.status) || BLOCKED.has(session.status)) {
1773
- return session;
1982
+ emptyPolls = page.items.length === 0 ? emptyPolls + 1 : 0;
1983
+ if (isFollowStopStatus(page.status) || page.items.length > 0 || emptyPolls >= 3) {
1984
+ const session = await requestWithFollowRetry({
1985
+ retry,
1986
+ maxOutageMs,
1987
+ dependencies,
1988
+ operation: async () => expectShape(
1989
+ await dependencies.request(path`/sessions/${sessionId}`, {
1990
+ timeoutMs: followRequestTimeoutMs(
1991
+ retry,
1992
+ maxOutageMs,
1993
+ dependencies.now()
1994
+ )
1995
+ }),
1996
+ "GET /sessions/{id}",
1997
+ { status: is.string }
1998
+ )
1999
+ });
2000
+ emptyPolls = 0;
2001
+ if (isFollowStopStatus(session.status)) {
2002
+ return session;
2003
+ }
1774
2004
  }
1775
- await sleep2(POLL_SECONDS);
2005
+ await dependencies.pollSleep(POLL_SECONDS);
1776
2006
  }
1777
2007
  }
1778
2008