@revyl/verify 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,8 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Add a standard-library-only Go SDK for web, iOS, Android, and early-access Mac and Linux computers. Computer commands, binary files, process handles, screenshots, recording metadata, and confirmed-release cleanup require an enabled staging organization and are not yet available in production. Capacity refusals have a distinct `COMPUTER_LIMIT` code; no start or operation is automatically replayed.
6
+
7
+ ## 0.2.0
8
+
9
+ Prepared for release; see npm for publication status.
10
+
11
+ - Enable Linux named and inline computer targets with the shared Mac command, file, process, screenshot, and confirmed-release cleanup surface. Both operating systems remain early access on staging for enabled organizations/OSes, not yet available in production.
12
+ - Add `COMPUTER_LIMIT` for queued work (including iOS) or concurrency admission refusals, without retrying or stopping a definitely refused start. Preserve `SESSION_BUSY` for operation limits.
13
+ - Explain the 120-second unclaimed-start failure without shortening the default 10-minute start timeout.
14
+ - Expose optional `expiresAt` and bound operation/screenshot observation by the hard 60-minute lifetime from create, while keeping explicit close possible.
15
+ - Add computer `recording()` metadata retrieval after terminal state and confirmed release, including after close. Missing recordings return null; signed URLs remain private, with no automatic waiting or download.
16
+ - Keep Linux CLI init intentionally unavailable; use inline targets or config entries. Computer instruction/validation remain locally unavailable.
17
+ - Observe mobile first-screen content best-effort for up to 10 seconds after connection/launch, without pretending the runtime provides rendered-readiness guarantees. Generated mobile examples begin with an instruction to wait for the first screen before validation.
18
+ - Retry structured computer screen-not-ready responses with bounded backoff during the fixed startup window; do not replay mutations or unrelated failures.
19
+ - Skip CI publication when the exact version is already on npm, require explicit administrator opt-in for new CI publications, fail closed on registry lookup errors, and document manual Mac publication, optional trusted publishing, and independent Go module tags.
20
+
21
+ Regression validation uses fake API responses. The [end-to-end record](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md) distinguishes baseline live runs from startup fixes still awaiting live re-verification.
22
+
3
23
  ## 0.1.0
4
24
 
5
- Initial release preparation; this entry does not mean the package has been published.
25
+ Published manually from a maintainer's Mac without provenance.
6
26
 
7
27
  - Open Revyl cloud web browsers, iOS simulators, and Android emulators from TypeScript.
8
28
  - Run natural-language instruction and validation steps, with typed failures and optional soft validation results.
package/README.md CHANGED
@@ -1,101 +1,138 @@
1
- # Verify
1
+ <h1 align="center">Verify</h1>
2
2
 
3
- **Open cloud browsers and mobile devices for Revyl's agent, or a Mac computer for commands and files, from TypeScript.**
3
+ <p align="center">
4
+ <em>Cloud browsers, mobile devices and computers from TypeScript and Go.</em>
5
+ </p>
4
6
 
5
- ```ts
6
- import { open } from '@revyl/verify';
7
+ <p align="center">
8
+ <a href="https://github.com/RevylAI/verify/blob/v0.2.0/CHANGELOG.md"><img src="https://img.shields.io/badge/version-0.2.0-9D61FF" alt="Source version" /></a>
9
+ <a href="https://github.com/RevylAI/verify/blob/v0.2.0/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0" /></a>
10
+ <a href="https://www.npmjs.com/package/@revyl/verify"><img src="https://img.shields.io/npm/v/@revyl/verify?color=9D61FF" alt="npm" /></a>
11
+ <a href="https://pkg.go.dev/github.com/RevylAI/verify/go"><img src="https://img.shields.io/badge/Go-reference-00ADD8?logo=go&logoColor=white" alt="Go reference" /></a>
12
+ <a href="https://github.com/RevylAI/verify/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/RevylAI/verify/ci.yml?branch=main&label=CI" alt="CI" /></a>
13
+ </p>
7
14
 
8
- const session = await open('ios');
9
- await session.instruction('Add the first product to the cart and open the cart');
10
- await session.validation('The cart lists one item with a price');
11
- await session.close();
12
- ```
15
+ ---
16
+
17
+ Verify is a TypeScript and Go library for [Revyl](https://revyl.com). Open a cloud browser, iOS simulator or Android emulator for natural-language instructions and validations, or an early-access Mac/Linux computer for commands, files, processes and screenshots. Call it from a script, coding agent or your existing test runner.
18
+
19
+ ## For coding agents
20
+
21
+ Run these from the project's directory with Node.js ≥22.18. The authentication command requires the [Revyl CLI](https://github.com/RevylAI/revyl-cli#install); if `REVYL_API_KEY` is already supplied securely, skip login.
13
22
 
14
23
  ```bash
15
- npx @revyl/verify init # writes verify.config.ts and verify/example.ts
16
- node verify/example.ts # opens the session, prints a link to watch it live, runs the steps
24
+ npm install --save-dev @revyl/verify
25
+ npx @revyl/verify init --platform ios
26
+ revyl auth login
27
+ npx @revyl/verify whoami
17
28
  ```
18
29
 
19
- Verify is a small TypeScript library on top of [Revyl](https://revyl.com). Open a browser or mobile device for **instruction** and **validation** steps, or a Mac computer for commands, files, processes, and screenshots. It has no test framework of its own, so you call it from wherever you already write code:
30
+ If login prints an approval URL, share it privately with the user so they can approve it in their browser. For unattended runs, supply `REVYL_API_KEY` through a secret manager; never put credentials in argv, source or logs. Configure the generated `verify.config.ts` before running `node verify/example.ts`. Starting a cloud session requires an authorized account and workload.
20
31
 
21
- - **A plain script.** A failed validation throws, the script exits non-zero, and Verify closes the session on the way out.
22
- - **A coding agent.** An agent can check its own change on a real device in five lines, then read a one-line outcome per step and a link to the recording.
23
- - **CI.** Run the script in a job. The browsers and devices run in Revyl's cloud, so the runner needs nothing but Node and an API key.
24
- - **Your test runner.** Call it inside node:test, Vitest, Jest, or Playwright Test, next to the tests you already have.
32
+ ## Install
25
33
 
26
- ## Quick start
34
+ ### npm
27
35
 
28
- You need Node.js 22.18 or later and a Revyl account.
36
+ Use Node.js ≥22.18:
29
37
 
30
38
  ```bash
31
- npx @revyl/verify init
32
39
  npm install --save-dev @revyl/verify
33
- export REVYL_API_KEY=... # or sign in with the Revyl CLI: revyl auth login
34
- node verify/example.ts
35
40
  ```
36
41
 
37
- Full walkthrough: [getting started](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/getting-started.md).
42
+ ### Go
43
+
44
+ Use Go ≥1.26.9 in an existing Go module:
45
+
46
+ ```bash
47
+ go get github.com/RevylAI/verify/go@main
48
+ ```
49
+
50
+ There is no Go release tag yet. `@main` resolves to a pseudo-version; keep the generated `go.mod` and `go.sum` to pin it. Go tags are independent of npm versions. The SDK uses only the standard library.
51
+
52
+ ## Authenticate
53
+
54
+ Both SDKs use this credential order:
38
55
 
39
- ## The API
56
+ 1. A usable stored CLI login marked `local_auth_override`.
57
+ 2. `REVYL_API_KEY` from the environment.
58
+ 3. Another usable CLI login stored by `revyl auth login`.
40
59
 
41
- | Call | What it does |
42
- | --- | --- |
43
- | `open(target)` | Starts a Revyl cloud browser, device, or Mac computer, from a target name in `verify.config.ts` or a target object, and waits until it is ready |
44
- | `session.exec(argv, options?)` | Runs structured argv on a Mac computer; returns `{ exitCode, stdout, stderr }` |
45
- | `session.readFile(path)`, `writeFile(path, data)`, `stat(path)` | Reads a `Buffer`, writes a string or bytes, or returns `{ kind, size }` on a computer |
46
- | `session.processes()`, `terminate(id)` | Lists run-owned process handles or terminates one on a computer |
47
- | `session.instruction(text)` | Revyl's agent does what the text says. Throws `INSTRUCTION_FAILED` if it cannot. |
48
- | `session.validation(text)` | Revyl's agent checks that the text holds on screen. Throws `VALIDATION_FAILED` if it does not, or returns the result with `{ soft: true }`. |
49
- | `session.screenshot({ path })` | A PNG of the screen |
50
- | `session.viewerUrl` | The session in Revyl: live while it runs, its recording after |
51
- | `session.close()` | Stops the session. Verify also closes open sessions on Ctrl+C, on an uncaught error, and when a script ends without closing. |
60
+ An expired access token can fall back to its stored API key. Set environment keys through a secret manager, or sign in interactively:
52
61
 
53
- Revyl failures are `VerifyError`s with a stable code, a safe reason, and the session link when one exists.
62
+ ```bash
63
+ revyl auth login
64
+ ```
54
65
 
55
- ## Platforms
66
+ See [configuration](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/configuration.md) for credential and endpoint options.
56
67
 
57
- | | Web | iOS | Android | Mac | Linux |
58
- | --- | --- | --- | --- | --- | --- |
59
- | Runs on | Revyl cloud browser | Revyl iOS simulator | Revyl Android emulator | Revyl macOS computer | Contract only; open unavailable |
60
- | Opens | A public URL, or a web build in Revyl | A build in Revyl, a build URL, or an installed app | A build in Revyl, a build URL, or an installed app | A workspace, no app id | Nothing in this release |
61
- | `instruction`, `validation` | Yes | Yes | Yes | Unavailable | Unavailable |
62
- | Commands, files, processes | Unavailable | Unavailable | Unavailable | Early access on staging only for enabled organizations; not yet available in Revyl production | Contract only |
63
- | `screenshot`, `viewerUrl` | Yes | Yes | Yes | Yes | Unconfirmed |
68
+ ## Quick Start
64
69
 
65
- Steps on web go through the same Revyl API as on mobile. Production is the default; optional environment overrides are documented in [web.md](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/web.md). A backend without web-step support returns `NOT_AVAILABLE_ON_PLATFORM`.
70
+ ### TypeScript
66
71
 
67
- Each step runs Revyl's agent on the live session every time. Verify does not cache or replay steps, and Revyl does not carry values from one step to the next.
72
+ Run `npx @revyl/verify init --platform ios`. For this Settings example, replace the generated `app` block with `app: { bundleId: 'com.apple.Preferences' }`. Save the following as `verify/example.ts` and run `node verify/example.ts`. For your own uploaded app, set `app.appId` and adapt the steps.
68
73
 
69
- For a Mac workspace, run `npx @revyl/verify init --platform mac`; it generates a command and file-round-trip script with no app id or agent steps. Mac computers are early access and not yet available in Revyl production. They work only on Revyl staging for enabled organizations using the optional `REVYL_BACKEND_URL` override and matching credentials. Linux targets are typed but `open()` rejects them with `NOT_AVAILABLE_ON_PLATFORM` before credentials or network access. See [computers](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors) and the [Linux contract](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/linux-computers-contract.md).
74
+ ```ts
75
+ import { open } from '@revyl/verify';
76
+ const session = await open('ios');
77
+ try {
78
+ await session.instruction('Open General, then About');
79
+ await session.validation('The screen shows the operating system version');
80
+ } finally {
81
+ await session.close();
82
+ }
83
+ ```
70
84
 
71
- ## Docs
85
+ For web, use `init --platform web` with a Revyl web app ID and public HTTPS URL. See [getting started](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/getting-started.md).
86
+
87
+ ### Go
88
+
89
+ Inside a function returning `error`, with a bounded `ctx` and imports for `context`, `time`, and `verify "github.com/RevylAI/verify/go"`:
90
+
91
+ ```go
92
+ session, err := verify.Open(ctx, verify.Target{Platform: verify.IOS, BundleID: "com.apple.Preferences"})
93
+ if err != nil {
94
+ return err
95
+ }
96
+ defer func() {
97
+ cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
98
+ defer cancel()
99
+ _ = session.Close(cleanup)
100
+ }()
101
+ if _, err := session.Instruction(ctx, "Open General, then About"); err != nil {
102
+ return err
103
+ }
104
+ _, err = session.Validation(ctx, "The screen shows the operating system version")
105
+ return err
106
+ ```
72
107
 
73
- - [Getting started](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/getting-started.md) and [API](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/api.md)
74
- - [Configuration and CLI](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/configuration.md)
75
- - [Web](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/web.md) and [iOS and Android](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/mobile.md)
76
- - [Mac computers](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md) and the [deferred Linux contract](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/linux-computers-contract.md)
77
- - [CI and test runners](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/ci.md) and [error codes](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/errors.md)
78
- - [For coding agents](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/agents.md)
108
+ This sketch ignores cleanup errors; the [complete Go examples](https://github.com/RevylAI/verify/blob/v0.2.0/go/README.md) handle them. Go has no process-exit handlers. TypeScript's automatic cleanup is best effort, bounded to 15 seconds, and skips signal/error handlers that already have listeners. Always close explicitly: device close acknowledges a stop request; computer close requires terminal state and confirmed release.
79
109
 
80
- Examples: [web store](https://github.com/RevylAI/verify/tree/main/examples/web-store), [mobile settings](https://github.com/RevylAI/verify/tree/main/examples/mobile-settings), and [Mac workspace](https://github.com/RevylAI/verify/tree/main/examples/mac-workspace).
110
+ ### Platforms
81
111
 
82
- ## How it works
112
+ | Platform | Instructions / validations | Commands / files / processes | PNG screenshot | Availability |
113
+ | --- | :---: | :---: | :---: | --- |
114
+ | Web | Yes | — | Yes | Production; selected backend must support web steps |
115
+ | iOS simulator | Yes | — | Yes | Production |
116
+ | Android emulator | Yes | — | Yes | Production |
117
+ | Mac computer | — | Yes | Yes | Staging-only early access; not in production |
118
+ | Linux computer | — | Yes | Yes | Staging-only early access; not in production |
83
119
 
84
- Verify is a client for [Revyl's public API](https://docs.revyl.com). `open()` starts a device session (registering a web URL as a build first when needed) and waits until it is ready. Each instruction or validation runs as a live step on the session: Verify submits it, polls its status until Revyl reports a result, and cancels it on Revyl if it runs past its timeout. `close()` stops the session. Requests identify themselves with `X-Revyl-Client: verify` and a `revyl-verify/<version>` user agent.
120
+ Computers require enablement for your organization and requested OS, plus approved `REVYL_BACKEND_URL`, matching `REVYL_APP_URL` and credentials. They have a hard 60-minute lifetime from create. Mac supports `init --platform mac`; Linux init is unavailable, so use `open({ platform: 'linux' })` or a config target. See [computers](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/computers.md).
85
121
 
86
- For computers, `open()` creates a computer run and waits for its report to become active. Commands use request UUIDs and async operation polling without resubmitting mutations; `close()` stops the run. See [the computer transport](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#lifecycle-and-transport).
122
+ ## Documentation
87
123
 
88
- ## Develop
124
+ - [Documentation index](https://github.com/RevylAI/verify/tree/v0.2.0/packages/verify/docs/) · [API](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/api.md) · [CI](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/ci.md) · [Coding agents](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/agents.md)
125
+ - [End-to-end results and limitations](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md)
126
+ - [Examples](https://github.com/RevylAI/verify/tree/v0.2.0/examples/): [Web store](https://github.com/RevylAI/verify/tree/v0.2.0/examples/web-store/) · [Mobile Settings](https://github.com/RevylAI/verify/tree/v0.2.0/examples/mobile-settings/) · [Mac workspace](https://github.com/RevylAI/verify/tree/v0.2.0/examples/mac-workspace/) · [Go](https://github.com/RevylAI/verify/blob/v0.2.0/go/README.md)
127
+ - [Contributing](https://github.com/RevylAI/verify/blob/v0.2.0/CONTRIBUTING.md) · [Security](https://github.com/RevylAI/verify/blob/v0.2.0/SECURITY.md) · [Releasing](https://github.com/RevylAI/verify/blob/v0.2.0/RELEASING.md)
89
128
 
90
- ```bash
91
- npm install
92
- npm run build
93
- npm test # unit tests against a mocked Revyl API; no account needed
94
- npm run typecheck
95
- ```
129
+ ## Troubleshooting
96
130
 
97
- The library lives in [`packages/verify`](https://github.com/RevylAI/verify/tree/main/packages/verify) and the examples in [`examples/`](https://github.com/RevylAI/verify/tree/main/examples). Read [Contributing](https://github.com/RevylAI/verify/blob/main/CONTRIBUTING.md), [Security](https://github.com/RevylAI/verify/blob/main/SECURITY.md), and [Releasing](https://github.com/RevylAI/verify/blob/main/RELEASING.md) before changing or publishing the package.
131
+ - **`NOT_AVAILABLE_ON_PLATFORM`:** computer agent steps and device command/file methods are unavailable. Computers also need staging enablement; web steps need backend support. Check the [platform contract](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/errors.md).
132
+ - **`COMPUTER_LIMIT`:** admission was refused because of queued work or a concurrency limit. Wait for capacity; Verify does not retry creation or stop a definitely refused start.
133
+ - **Blank first screen:** mobile startup makes a bounded, best-effort blank-frame observation, not a rendered/foreground-readiness guarantee. Begin with an instruction that reaches the screen you need. Computer capture retries structured startup screen-not-ready responses for a fixed 30-second window; Linux screenshot retry is tests-only, not proven live.
134
+ - **A report's counters look successful but validation failed:** inspect `effective_status`, `validation_result` and `validations_passed` against `total_validations`, not transport counts. Failed SDK validations throw by default. See [reading the verdict](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md#reading-the-verdict).
98
135
 
99
136
  ## License
100
137
 
101
- [Apache 2.0](https://github.com/RevylAI/verify/blob/main/LICENSE)
138
+ [Apache 2.0](https://github.com/RevylAI/verify/blob/v0.2.0/LICENSE).
@@ -130,6 +130,12 @@ export interface ComputerReport {
130
130
  state: ComputerState;
131
131
  release: 'pending' | 'confirmed' | 'unknown';
132
132
  failure_reason?: string | null;
133
+ expires_at?: string | null;
134
+ }
135
+ export interface ComputerRecordingResponse {
136
+ url: string;
137
+ duration_seconds?: number | null;
138
+ started_at?: string | null;
133
139
  }
134
140
  export interface ComputerProcessHandle {
135
141
  computer_session_id: string;
package/dist/cli/init.js CHANGED
@@ -8,7 +8,7 @@ const CONFIG_FILE = 'verify.config.ts';
8
8
  const EXAMPLE_SCRIPT = join('verify', 'example.ts');
9
9
  export function validateInitAnswers(answers) {
10
10
  if (!['web', 'ios', 'android', 'mac'].includes(answers.platform)) {
11
- throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux computers are not available yet.");
11
+ throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux init is unavailable; use open({ platform: 'linux' }) or a Linux config target.");
12
12
  }
13
13
  if (answers.platform === 'mac' && answers.appId !== undefined) {
14
14
  throw new VerifyError('INVALID_ARGUMENT', '--app-id does not apply to Mac computers.');
@@ -101,8 +101,7 @@ try {
101
101
  const steps = platform === 'web'
102
102
  ? `await session.instruction('Follow the link on the page');
103
103
  await session.validation('A page about domains reserved for documentation is shown');`
104
- : `// Describe what a person would do and see, for example:
105
- // await session.instruction('Sign in as the demo user and open Settings');
104
+ : `await session.instruction('Wait for the app to finish opening and show its first screen');
106
105
  await session.validation('The app has opened and shows its first screen without an error');`;
107
106
  return `import { open } from '${PACKAGE_NAME}';
108
107
 
package/dist/cli/main.js CHANGED
@@ -6,18 +6,18 @@ import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
6
6
  import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
7
7
  import { PACKAGE_NAME, VERSION } from '../version.js';
8
8
  import { initProject, validateInitAnswers } from './init.js';
9
- const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and Mac computers from your code
9
+ const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and computers from your code
10
10
 
11
11
  Usage
12
- verify init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force]
12
+ npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force]
13
13
  Write verify.config.ts and an example script, verify/example.ts
14
- verify whoami
14
+ npx ${PACKAGE_NAME} whoami
15
15
  Check which Revyl account your credentials belong to
16
16
 
17
17
  Authentication
18
18
  REVYL_API_KEY, or a login from the Revyl CLI ('revyl auth login').
19
19
 
20
- Docs: https://github.com/RevylAI/verify/tree/main/packages/verify/docs
20
+ Docs: https://github.com/RevylAI/verify/tree/v${VERSION}/packages/verify/docs
21
21
  `;
22
22
  const USAGE_CODES = new Set(['INVALID_ARGUMENT', 'AUTH_MISSING', 'AUTH_REJECTED']);
23
23
  export async function main(argv) {
@@ -70,7 +70,7 @@ async function initCommand(args) {
70
70
  const steps = [
71
71
  `npm install --save-dev ${PACKAGE_NAME}`,
72
72
  "Authenticate: export REVYL_API_KEY=<key from Revyl settings>, or run 'revyl auth login'",
73
- ...(answers.platform === 'mac' ? ['Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations; use the optional REVYL_BACKEND_URL override with staging credentials. Ask support@revyl.ai for access. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors'] : []),
73
+ ...(answers.platform === 'mac' ? [`Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations; use the optional REVYL_BACKEND_URL override with staging credentials. Ask support@revyl.ai for access. See https://github.com/RevylAI/verify/blob/v${VERSION}/packages/verify/docs/computers.md#availability-and-errors`] : []),
74
74
  ...(answers.platform === 'mac' || answers.appId ? [] : ['Put your Revyl app id in verify.config.ts (or set REVYL_APP_ID)']),
75
75
  'node verify/example.ts',
76
76
  ];
@@ -1,19 +1,24 @@
1
1
  import { RevylClient } from './api/client.js';
2
+ import type { ComputerPlatform } from './api/types.js';
2
3
  import type { ResolvedComputerTarget } from './config.js';
3
- import { type ComputerProcess, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
4
+ import { type ComputerProcess, type ComputerRecording, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
4
5
  export declare class ComputerSession implements Session {
5
6
  private readonly client;
6
7
  private readonly options;
7
- readonly platform: "mac";
8
+ readonly platform: ComputerPlatform;
8
9
  readonly target: string;
9
10
  readonly id: `${string}-${string}-${string}-${string}-${string}`;
10
11
  readonly viewerUrl: string;
11
12
  readonly steps: StepResult[];
12
13
  private readonly lifetime;
13
14
  private closePromise;
15
+ private expiresAtMs;
16
+ private screenReadyUntilMs;
14
17
  private constructor();
15
18
  static start(client: RevylClient, target: ResolvedComputerTarget, options: SessionOptions): Promise<ComputerSession>;
16
19
  get closed(): boolean;
20
+ get expiresAt(): Date | undefined;
21
+ recording(): Promise<ComputerRecording | null>;
17
22
  instruction(): Promise<never>;
18
23
  validation(): Promise<never>;
19
24
  screenshot(options?: {
@@ -31,6 +36,7 @@ export declare class ComputerSession implements Session {
31
36
  private request;
32
37
  private report;
33
38
  private assertOpen;
39
+ private observationSignal;
34
40
  private unavailable;
35
41
  private log;
36
42
  }
@@ -2,54 +2,62 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
- import { responseDetail, sessionViewerUrl } from './api/client.js';
5
+ import { responseDetail, responseErrorCode, sessionViewerUrl } from './api/client.js';
6
6
  import { trackSession, untrackSession } from './cleanup.js';
7
7
  import { VerifyError, isVerifyError } from './errors.js';
8
8
  import { pngSize } from './session.js';
9
9
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
10
  const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
11
11
  const MAX_OUTPUT_BYTES = 1_048_576;
12
- const MAC_AVAILABILITY = 'Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors';
12
+ const COMPUTER_AVAILABILITY = 'Computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations and operating systems. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors';
13
+ const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
13
14
  const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
14
15
  export class ComputerSession {
15
16
  client;
16
17
  options;
17
- platform = 'mac';
18
+ platform;
18
19
  target;
19
20
  id = randomUUID();
20
21
  viewerUrl;
21
22
  steps = [];
22
23
  lifetime = new AbortController();
23
24
  closePromise;
25
+ expiresAtMs;
26
+ screenReadyUntilMs = 0;
24
27
  constructor(client, target, options) {
25
28
  this.client = client;
26
29
  this.options = options;
27
30
  this.target = target.name;
31
+ this.platform = target.platform;
28
32
  this.viewerUrl = sessionViewerUrl(this.id, 'computer');
29
33
  }
30
34
  static async start(client, target, options) {
31
- if (target.platform !== 'mac')
32
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet.');
33
35
  const session = new ComputerSession(client, target, options);
34
36
  const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
37
+ let createAcknowledged = false;
35
38
  trackSession(session);
36
39
  try {
37
- session.log(`starting mac computer ${session.viewerUrl}. ${MAC_AVAILABILITY}`);
38
- let report = session.report(await session.request('POST', '', { computer_session_id: session.id, os: 'macos' }, signal));
40
+ session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
41
+ const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
42
+ createAcknowledged = true;
43
+ let report = session.report(response);
39
44
  for (;;) {
40
45
  if (report.state === 'active')
41
46
  break;
42
- if (report.state !== 'starting')
43
- throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}). See ${session.viewerUrl}`);
47
+ if (report.state !== 'starting') {
48
+ const reason = report.failure_reason === 'no_host' ? ' No host claimed the start within the 120-second unclaimed-start window.' : report.failure_reason === 'device_limit' ? ` ${COMPUTER_CAPACITY}` : '';
49
+ throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
50
+ }
44
51
  await delay(2_000, undefined, { signal });
45
52
  report = session.report(await session.request('GET', '/report', undefined, signal));
46
53
  }
54
+ session.screenReadyUntilMs = Date.now() + 30_000;
47
55
  session.log('ready');
48
56
  return session;
49
57
  }
50
58
  catch (error) {
51
59
  const timedOut = signal.aborted;
52
- if (isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT'].includes(error.code)) {
60
+ if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
53
61
  session.lifetime.abort();
54
62
  untrackSession(session);
55
63
  }
@@ -57,13 +65,40 @@ export class ComputerSession {
57
65
  await session.close().catch(() => undefined);
58
66
  }
59
67
  if (timedOut)
60
- throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}. Inspect ${session.viewerUrl}`);
68
+ throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}. Inspect ${session.viewerUrl}`);
61
69
  throw error;
62
70
  }
63
71
  }
64
72
  get closed() {
65
73
  return this.closePromise !== undefined;
66
74
  }
75
+ get expiresAt() {
76
+ return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
77
+ }
78
+ async recording() {
79
+ const signal = AbortSignal.timeout(30_000);
80
+ const report = this.report(await this.request('GET', '/report', undefined, signal));
81
+ if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
82
+ throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
83
+ }
84
+ const response = await this.request('GET', '/recording', undefined, signal);
85
+ if (response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
86
+ return null;
87
+ const raw = parseComputerJson(response);
88
+ if (!raw || typeof raw.url !== 'string' || !/^https:\/\//i.test(raw.url) || /[\x00-\x20\x7f]/.test(raw.url))
89
+ throw invalidResponse();
90
+ let url;
91
+ try {
92
+ url = new URL(raw.url);
93
+ }
94
+ catch {
95
+ throw invalidResponse();
96
+ }
97
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash || (raw.duration_seconds != null && (typeof raw.duration_seconds !== 'number' || !Number.isFinite(raw.duration_seconds) || raw.duration_seconds < 0)))
98
+ throw invalidResponse();
99
+ const startedAt = raw.started_at == null ? undefined : parseTimestamp(raw.started_at);
100
+ return { url: raw.url, ...(raw.duration_seconds != null ? { durationSeconds: raw.duration_seconds } : {}), ...(startedAt ? { startedAt } : {}) };
101
+ }
67
102
  instruction() {
68
103
  return this.unavailable('instruction');
69
104
  }
@@ -72,7 +107,38 @@ export class ComputerSession {
72
107
  }
73
108
  async screenshot(options = {}) {
74
109
  this.assertOpen();
75
- const response = await this.request('GET', '/screen/screenshot', undefined, this.lifetime.signal);
110
+ const signal = this.observationSignal(30_000);
111
+ let retrySignal = signal;
112
+ let response;
113
+ let transient = false;
114
+ let backoffMs = 500;
115
+ const notReady = () => new VerifyError('SESSION_BUSY', `The computer desktop is not ready for screenshots after its 30-second startup window. Try again later. See ${this.viewerUrl}`);
116
+ try {
117
+ for (;;) {
118
+ this.assertOpen();
119
+ response = await this.request('GET', '/screen/screenshot', undefined, retrySignal);
120
+ if (response.status >= 200 && response.status < 300)
121
+ break;
122
+ const remainingMs = this.screenReadyUntilMs - Date.now();
123
+ if (remainingMs <= 0)
124
+ throw notReady();
125
+ if (!transient)
126
+ retrySignal = AbortSignal.any([signal, AbortSignal.timeout(remainingMs)]);
127
+ transient = true;
128
+ await delay(Math.min(backoffMs, remainingMs), undefined, { signal: retrySignal });
129
+ if (Date.now() >= this.screenReadyUntilMs)
130
+ throw notReady();
131
+ backoffMs = Math.min(backoffMs * 2, 2_000);
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (transient && !this.lifetime.signal.aborted && (this.expiresAtMs === undefined || Date.now() < this.expiresAtMs) && retrySignal.aborted && Date.now() >= this.screenReadyUntilMs)
136
+ throw notReady();
137
+ if (retrySignal.aborted)
138
+ throw new VerifyError('TIMEOUT', `Computer screenshot observation was cancelled or exceeded its deadline. See ${this.viewerUrl}`);
139
+ throw error;
140
+ }
141
+ this.assertOpen();
76
142
  const raw = parseComputerJson(response);
77
143
  const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
78
144
  const size = pngSize(bytes);
@@ -152,7 +218,7 @@ export class ComputerSession {
152
218
  }
153
219
  catch (error) {
154
220
  if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
155
- const message = `${error.message} A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}`;
221
+ const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
156
222
  this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
157
223
  throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
158
224
  }
@@ -172,10 +238,12 @@ export class ComputerSession {
172
238
  this.assertOpen();
173
239
  const requestId = randomUUID();
174
240
  const operationId = computerOperationId(this.id, requestId);
175
- const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(310_000)]);
241
+ const signal = this.observationSignal(310_000);
176
242
  try {
177
243
  let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
178
244
  for (;;) {
245
+ if (signal.aborted)
246
+ throw signal.reason;
179
247
  if (response.status !== 202) {
180
248
  const body = parseComputerJson(response);
181
249
  if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
@@ -224,7 +292,7 @@ export class ComputerSession {
224
292
  const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
225
293
  let response;
226
294
  try {
227
- response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
295
+ response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
228
296
  }
229
297
  catch (error) {
230
298
  if (method === 'POST' || suffix.startsWith('/operations/'))
@@ -235,10 +303,24 @@ export class ComputerSession {
235
303
  }
236
304
  if (response.status >= 200 && response.status < 300)
237
305
  return response;
306
+ if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
307
+ return response;
238
308
  const detail = responseDetail(response);
239
- if (detail === 'computer_not_enabled' || (method === 'POST' && suffix === '' && response.status === 503 && detail === 'runtime_unavailable')) {
240
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${MAC_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
309
+ if (suffix === '/screen/screenshot') {
310
+ let raw = null;
311
+ try {
312
+ raw = JSON.parse(new TextDecoder().decode(response.body));
313
+ }
314
+ catch { }
315
+ const code = raw?.detail ?? raw?.message ?? raw?.error;
316
+ if ((code === 'screen_unavailable' && (response.status === 409 || response.status === 503)) || (code === 'screen_starting' && response.status === 409))
317
+ return response;
241
318
  }
319
+ if (detail === 'computer_not_enabled' || (response.status === 503 && detail === 'runtime_unavailable')) {
320
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
321
+ }
322
+ if (response.status === 429 && detail === 'computer_limit')
323
+ throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`);
242
324
  let code = 'API_ERROR';
243
325
  if (detail === 'run_closed')
244
326
  code = 'SESSION_ENDED';
@@ -250,7 +332,7 @@ export class ComputerSession {
250
332
  code = 'INVALID_ARGUMENT';
251
333
  else if (response.status === 401 || response.status === 403)
252
334
  code = 'AUTH_REJECTED';
253
- else if (detail === 'runtime_unavailable' || detail === 'start_failed' || detail === 'computer_limit')
335
+ else if (detail === 'start_failed')
254
336
  code = 'SESSION_START_FAILED';
255
337
  else if (response.status === 409 || detail === 'operation_limit')
256
338
  code = 'SESSION_BUSY';
@@ -265,14 +347,20 @@ export class ComputerSession {
265
347
  const report = parseComputerJson(response);
266
348
  if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
267
349
  throw invalidResponse();
350
+ if (report.expires_at != null)
351
+ this.expiresAtMs = parseTimestamp(report.expires_at).getTime();
268
352
  return report;
269
353
  }
270
354
  assertOpen() {
271
- if (this.closed)
272
- throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed.`);
355
+ if (this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
356
+ throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
357
+ }
358
+ observationSignal(timeoutMs) {
359
+ const remainingMs = this.expiresAtMs === undefined ? timeoutMs : Math.min(timeoutMs, this.expiresAtMs - Date.now());
360
+ return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
273
361
  }
274
362
  unavailable(method) {
275
- return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on mac computers; use exec() and file operations. See ${this.viewerUrl}`));
363
+ return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
276
364
  }
277
365
  log(line) {
278
366
  this.options.log?.(`${this.target}: ${line}`);
@@ -312,6 +400,14 @@ function decodeBytes(value, maxBytes) {
312
400
  function invalidResponse() {
313
401
  return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
314
402
  }
403
+ function parseTimestamp(value) {
404
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))
405
+ throw invalidResponse();
406
+ const calendarTime = new Date(`${value.slice(0, 19)}Z`);
407
+ if (!Number.isFinite(calendarTime.getTime()) || calendarTime.toISOString().slice(0, 19) !== value.slice(0, 19))
408
+ throw invalidResponse();
409
+ return new Date(value);
410
+ }
315
411
  function parseComputerJson(response) {
316
412
  try {
317
413
  return JSON.parse(new TextDecoder().decode(response.body));
package/dist/config.d.ts CHANGED
@@ -46,7 +46,7 @@ export interface MobileTargetConfig {
46
46
  startTimeout?: number;
47
47
  idleTimeoutSeconds?: number;
48
48
  }
49
- /** A Revyl computer: a cloud macOS machine that runs commands and files instead of agent steps. See docs/computers.md. */
49
+ /** A Revyl computer that runs commands and files instead of agent steps. See docs/computers.md. */
50
50
  export interface ComputerTargetConfig {
51
51
  platform: ComputerPlatform;
52
52
  startTimeout?: number;