diff --git a/.github/workflows/self-test-reference.yml b/.github/workflows/self-test-reference.yml new file mode 100644 index 00000000..a9dae5a1 --- /dev/null +++ b/.github/workflows/self-test-reference.yml @@ -0,0 +1,108 @@ +name: Self-test reference check + +on: + schedule: + - cron: '17 7 * * 1' + repository_dispatch: + types: + - self-test-reference-source-updated + pull_request: + paths: + - 'host/self-test-reference.mdx' + - 'host/common-errors-diagnostics.mdx' + - 'host/how-to-self-test.mdx' + - 'host/verification-stages.mdx' + - 'scripts/generate_self_test_reference.py' + - 'docs.json' + - 'package.json' + - '.github/workflows/self-test-reference.yml' + workflow_dispatch: + inputs: + vast_cli_ref: + description: 'vast-ai/vast-cli ref to generate from' + required: false + default: 'master' + self_test_ref: + description: 'vast-ai/self-test ref to generate from' + required: false + default: 'main' + +jobs: + verify-self-test-reference: + runs-on: ubuntu-latest + steps: + - name: Checkout docs + uses: actions/checkout@v4 + with: + path: docs + + - name: Decide whether private source repos can be checked out + id: source-access + env: + SOURCE_TOKEN: ${{ secrets.VAST_DOCS_SOURCE_TOKEN }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ] && [ "$HEAD_REPO" != "${{ github.repository }}" ] && [ -z "$SOURCE_TOKEN" ]; then + echo "can_verify=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping self-test reference sync check for fork PR because VAST_DOCS_SOURCE_TOKEN is unavailable. Run workflow_dispatch or use an upstream branch to verify private source repos." + else + echo "can_verify=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout vast-cli source + if: steps.source-access.outputs.can_verify == 'true' + uses: actions/checkout@v4 + with: + repository: vast-ai/vast-cli + ref: ${{ github.event.client_payload.vast_cli_ref || github.event.inputs.vast_cli_ref || 'master' }} + path: vast-cli-source + token: ${{ secrets.VAST_DOCS_SOURCE_TOKEN || github.token }} + + - name: Checkout self-test source + if: steps.source-access.outputs.can_verify == 'true' + uses: actions/checkout@v4 + with: + repository: vast-ai/self-test + ref: ${{ github.event.client_payload.self_test_ref || github.event.inputs.self_test_ref || 'main' }} + path: self-test-source + token: ${{ secrets.VAST_DOCS_SOURCE_TOKEN || github.token }} + + - uses: actions/setup-python@v5 + if: steps.source-access.outputs.can_verify == 'true' + with: + python-version: '3.11' + + - name: Regenerate self-test reference + if: steps.source-access.outputs.can_verify == 'true' + working-directory: docs + run: | + PYTHONDONTWRITEBYTECODE=1 npm run generate-self-test-reference -- \ + --vast-cli ../vast-cli-source \ + --self-test ../self-test-source + + - name: Fail if generated page is out of sync + if: steps.source-access.outputs.can_verify == 'true' + working-directory: docs + run: | + if ! git diff --quiet host/self-test-reference.mdx; then + echo "::error::host/self-test-reference.mdx is out of sync with vast-ai/vast-cli or vast-ai/self-test." + echo "::error::Run 'npm run generate-self-test-reference -- --vast-cli PATH_TO_VAST_CLI --self-test PATH_TO_SELF_TEST' and commit the result." + git diff --stat host/self-test-reference.mdx + git diff -- host/self-test-reference.mdx + exit 1 + fi + + - name: Fail if Python bytecode was generated + if: steps.source-access.outputs.can_verify == 'true' + working-directory: docs + run: | + if find scripts -name '__pycache__' -o -name '*.pyc' | grep -q .; then + echo "::error::Generated Python bytecode should not be committed or produced by the docs generation check." + find scripts -name '__pycache__' -o -name '*.pyc' + exit 1 + fi + + - name: Report skipped private-source validation + if: steps.source-access.outputs.can_verify != 'true' + run: | + echo "Self-test reference sync check skipped because this fork PR cannot access private source repositories." diff --git a/README.md b/README.md index c5dc285a..b9e0711d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,36 @@ API endpoint specs live in `api-reference/openapi/yaml/` (one file per endpoint) 4. Preview locally: `mint dev`. 5. Commit both your YAML edit AND the regenerated `api-reference/openapi.yaml`, then open a PR. CI re-runs the build and fails if `openapi.yaml` is out of sync with the sources. +## Updating the self-test reference + +The host self-test reference page is generated from the Vast CLI diagnostics and the self-test image metadata. + +1. Update the relevant self-test source in `vast-ai/vast-cli` or `vast-ai/self-test`. +2. Regenerate the docs page: + +```bash +npm run generate-self-test-reference -- --vast-cli ../vast-cli --self-test ../self-test +``` + +3. Review and commit `host/self-test-reference.mdx` with the source change or in the matching docs PR. +4. CI re-runs the generator against `vast-ai/vast-cli` and `vast-ai/self-test` and fails if the committed page is out of sync. A weekly scheduled run catches source drift even when a source repository does not send a dispatch event. + +For cross-repo private checkouts, configure the docs repository secret `VAST_DOCS_SOURCE_TOKEN` with read access to `vast-ai/vast-cli` and `vast-ai/self-test`. The workflow can also be run manually with custom `vast_cli_ref` and `self_test_ref` inputs while a source PR is under review. + +Source repositories can trigger an immediate docs check after self-test metadata changes by sending a repository dispatch event to `vast-ai/docs`: + +```json +{ + "event_type": "self-test-reference-source-updated", + "client_payload": { + "vast_cli_ref": "master", + "self_test_ref": "main" + } +} +``` + +Use the exact branch or SHA being validated when triggering this from a source PR workflow. Source-side dispatch is optional because the scheduled docs check provides a fallback; dispatch shortens the time before drift is reported. + ## Mintlify Information **[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)** diff --git a/REVIEW-QUESTIONS.md b/REVIEW-QUESTIONS.md new file mode 100644 index 00000000..4acb3215 --- /dev/null +++ b/REVIEW-QUESTIONS.md @@ -0,0 +1,135 @@ +# Reviewer inputs and Jira gates + +> Temporary review aid — removed before merge, like `review-server.mjs`. +> +> Start with the [Host docs review traceability audit](./REVIEW-TRACEABILITY.md) +> to see what is implemented, what still needs sign-off, and the CON-1519 +> bundle-ownership decisions for the meeting. +> +> The eight cross-cutting decisions below gate the Host Docs review. A current +> Jira audit also found page-specific questions for account setup, Network & +> Ports, Self-Test, and Host Diagnostics. Those appear beside the comments on +> the affected page in the port 4000 review panel, with direct Jira links. +> The first two decisions below unblock the review sequence. +> +> **Three ways to answer — pick whichever is easiest:** +> 1. Comment inline on this file in the [PR #185 diff](https://github.com/vast-ai/docs/pull/185/files). +> 2. In the local review kit, open `http://localhost:4000/review-questions`, select a question, and comment — it lands in the same feedback export as your page comments. +> 3. Reply on the linked Jira ticket. + +## Input 1. IA approval + +**Owner: Michele + docs owners · Round 0 · unblocks everything** + +Approve or modify the information architecture (CON-1518 decision points a–d): + +- (a) **Lifecycle sidebar** — Before You Host → Set Up → Verify & List → Operate → Reference, vs. some other top-level grouping. +- (b) **P0/P1/P2 priority order** — the ticket-volume-driven ordering of new docs. +- (c) **Supported Hardware as the #1 prevention doc** — even though it isn't the largest ticket bucket. +- (d) **Persona tags** — keep the visible chips (top-right of each page), restyle them, or drop to frontmatter-only convention. + +Blocks: every other review round. Detail: CON-1518. + +## Input 2. Review mechanics + +**Owner: Michele + docs owners** + +How do you want to review the content: one small PR per sidebar group (round), or staged review of [PR #185](https://github.com/vast-ai/docs/pull/185) with per-round checklists? PR #185 now contains all the work (former #153 is an ancestor of it). Either way, **#152 and #153 should be closed as superseded**. + +Blocks: scheduling of every content round. Detail: CON-1518, CON-1584. + +## Input 3. Pricing content review + +**Owner: Gobind / Solutions Engineering · Round 3 · CON-1256** + +Review the business/pricing positioning: [Pricing Your Listing](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/pricing-your-listing.mdx), [Market Metrics](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/market-metrics.mdx), [Optimize Your Earnings](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/optimization-guide.mdx) — in the preview: `/host/pricing-your-listing`, `/host/market-metrics`, `/host/optimization-guide`. + +Blocks: CON-1256 sign-off. Detail: CON-1256. + +## Input 4. Machine-error platform behavior + +**Owner: Backend source owner (Hanran) · CON-1531** + +Seven confirmations that set the "how long it persists" copy on [Machine Error Reference](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/machine-errors.mdx) (`/host/machine-errors` in the preview): + +1. Is the 2026-06-24 error catalog complete for host-visible machine errors? +2. For each error, which field displays it to hosts (`error_msg`, `error_note`, `error_description`, `vm_error_msg`, `vm_error_level`, other)? +3. Which errors appear on the Machines page vs. only in a failed rental/instance detail? +4. For machine-deverifying errors, what clears the error — next clean heartbeat, successful self-test, admin action, time decay? +5. For VM-offer-only errors, what is the approximate clean-report/TTL before VM offers return? +6. Should logged-only rental-attempt messages be public host docs, or internal/support-only? +7. Should the console deep-link docs by raw error string, normalized category, or both? + +Blocks: final wording on Machine Error Reference. Detail: CON-1531. + +## Input 5. Installer Wizard screenshot + +**Owner: Product · Rounds 0/2** + +Approve the Host Installer Wizard (TUI) screenshot in [Installing Host Software](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/installing-host-software.mdx) (`/host/installing-host-software#host-installer-wizard` in the preview) — or supply a replacement asset. + +Blocks: production merge (flagged since 2026-06-17). Detail: CON-1518 Jira attachment `image-20260617-135801.png`. + +## Input 6. Supported Hardware sign-off + +**Owner: Product · Round 1** + +Confirm [Supported Hardware](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/supported-hardware.mdx) (`/host/supported-hardware` in the preview): exact GPU-family coverage, OS/cgroup guidance, and alignment of the CPU rule between docs, self-test #6, and vast-cli #413. Related product asks on the radar: payment/tax edge cases (incl. W-8 for non-US hosts) and datacenter requirements wording. + +Blocks: CON-1516 sign-off; the highest-prevention doc going live. Detail: CON-1516. + +## Input 7. Host Teams engineering answers + +**Owner: Engineering · Round 6 · CON-1581** + +Five answers that gate publishing [Host Teams](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/host/host-teams.mdx) (`/host/host-teams` in the preview): + +1. Individual→team migration: what happens to existing machines and accrued earnings? +2. Install-command `undefined` bug in team context — status? +3. What is the exact flow for granting machine-registration rights to a team API key? (Flagged inside the draft page itself.) +4. Are `billing_read`-only roles viable for host teams? +5. Who owns billing/payouts when machines move into a team? + +Blocks: Host Teams page publication. Detail: CON-1581. + +## Input 8. Persona scope ruling + +**Owner: Docs team · Round 5** + +Do the generated `host/cli/*` and `host/sdk/*` reference pages need persona chips, or are generated reference pages exempt? All 39 authored pages are tagged and chip-synced (now lint-enforced via `npm run check-persona-chips`); the 33 generated pages are currently exempt by convention. + +Blocks: the literal reading of CON-1518's "tag every page"; the only remaining implementation wrinkle. Detail: CON-1518 (2026-06-29 comment). + +## Additional page-specific Jira gates + +These are intentionally shown on the affected page instead of expanding every +reviewer's checklist. Open the review panel on that page to see the questions, +owner, status, and direct Jira links. + +- **Account and installation:** setup-page machine installation key wording, + dedicated host account guidance, team registration permissions, and the + installer screenshot — [CON-1584](https://vastai.atlassian.net/browse/CON-1584), + [CON-1581](https://vastai.atlassian.net/browse/CON-1581), and + [CON-1518](https://vastai.atlassian.net/browse/CON-1518). +- **Network & Ports:** per-GPU versus per-instance port wording, TCP/UDP + behavior, port release timing, and exact failed-port evidence — + [CON-1514](https://vastai.atlassian.net/browse/CON-1514). +- **Verification / Self-Test:** confirm the authoritative verification queue + and wait-time facts. The generated actual-versus-required/stable-code + reference, source drift workflow, B300 RAM-cap wording, and older-GPU image + selection are now present in PR #185 — + [CON-1515](https://vastai.atlassian.net/browse/CON-1515), + [CON-1513](https://vastai.atlassian.net/browse/CON-1513), + [CON-1583](https://vastai.atlassian.net/browse/CON-1583), and + [CON-1419](https://vastai.atlassian.net/browse/CON-1419). +- **Host Diagnostics:** decide diagnostic-bundle intake, retention, first + triage, diagnostic ownership, and safe host-local artifact policy. The merged + `vastai dump-logs` workflow is documented; backend-only daemon/Docker/port + evidence remains a platform question — + [CON-1510](https://vastai.atlassian.net/browse/CON-1510), + [CON-1519](https://vastai.atlassian.net/browse/CON-1519), and + [CON-1514](https://vastai.atlassian.net/browse/CON-1514). + +--- + +*Non-blocking follow-ups already ticketed elsewhere: `vastai verify-status` / queue-position-by-GPU-tier (product/CLI feature), CLI error-string deep-linking (docs anchors are ready).* diff --git a/REVIEW-TRACEABILITY.md b/REVIEW-TRACEABILITY.md new file mode 100644 index 00000000..c6b96b4f --- /dev/null +++ b/REVIEW-TRACEABILITY.md @@ -0,0 +1,115 @@ +# Host docs review traceability + +Snapshot: 2026-07-13 + +Review PR: [vast-ai/docs#185](https://github.com/vast-ai/docs/pull/185) + +Jira epics: [CON-1187](https://vastai.atlassian.net/browse/CON-1187) and [CON-1509](https://vastai.atlassian.net/browse/CON-1509) + +## Purpose + +This review-only audit maps the Host documentation in PR #185 to the 16 child +tickets assigned to Hannes. It distinguishes work that is implemented from +facts, policy decisions, and sign-offs that still need an owner. + +This document does not reproduce internal support source material, customer +data, credentials, or machine-local research paths. It records only the +traceability needed to review the PR. + +## Ticket matrix + +| Jira | Current state | Evidence in or linked from PR #185 | Review verdict | +|---|---|---|---| +| [CON-1584](https://vastai.atlassian.net/browse/CON-1584) | BLOCKED | Host Account Security and Host CLI/API/SDK orientation, with links to canonical account and developer docs | Partial: Teams ownership, setup-key wording, screenshot/redaction, and review-stack decisions remain | +| [CON-1581](https://vastai.atlassian.net/browse/CON-1581) | BLOCKED | `/host/host-teams` covers context, ownership, roles, keys, CLI use, earnings, payouts, and recovery | Partial: migration semantics, the `undefined` install failure, registration permissions, and `billing_read` behavior need engineering answers | +| [CON-1531](https://vastai.atlassian.net/browse/CON-1531) | BLOCKED | `/host/machine-errors` provides a broad lookup, impact, remediation, and public/admin distinctions | Partial: catalog completeness, field/UI mapping, clearing/TTL rules, and public-error policy need backend answers | +| [CON-1518](https://vastai.atlassian.net/browse/CON-1518) | TO REVIEW | Lifecycle IA, overview split, persona chips, installer assets, and persona consistency check | Substantially documented; IA/persona and stakeholder sign-off remain | +| [CON-1517](https://vastai.atlassian.net/browse/CON-1517) | TO REVIEW | Source review and a human-reviewed answer pass were completed; PR commit `0cb28ff` distributes the answers across 33 canonical Host pages | Implemented in PR #185; shared product confirmations remain under their topic-specific tickets | +| [CON-1515](https://vastai.atlassian.net/browse/CON-1515) | TO REVIEW | Generated `/host/self-test-reference`, source-derived thresholds, runtime stages, image matrix, stable codes, bundle guidance, generator, and CI workflow | Implemented in PR #185; authoritative verification queue/wait-time wording still needs confirmation | +| [CON-1256](https://vastai.atlassian.net/browse/CON-1256) | TO REVIEW | Pricing, earnings, market metrics, optimization, payment, datacenter, tax, and persona guidance | Partial: Solutions Engineering/business review and content ownership remain | +| [CON-1077](https://vastai.atlassian.net/browse/CON-1077) | TO REVIEW | `/host/headless-install` provides an SSH-only setup path from first login through listing and Self-Test | Implemented in docs; reviewer sign-off remains | +| [CON-1583](https://vastai.atlassian.net/browse/CON-1583) | TO REVIEW | [self-test#3](https://github.com/vast-ai/self-test/pull/3) is merged; the generated reference documents the approximately 2 TB high-VRAM cap and B300 behavior | Implemented in runtime and PR #185; reviewer sign-off remains | +| [CON-1519](https://vastai.atlassian.net/browse/CON-1519) | TO REVIEW | [vast-cli#410](https://github.com/vast-ai/vast-cli/pull/410) is merged; Host Diagnostics and the generated reference document automatic bundles, `dump-logs`, redaction, caps, and opt-in host-local artifacts | Command and docs are implemented; operations ownership and safe transfer/retention policy remain | +| [CON-1514](https://vastai.atlassian.net/browse/CON-1514) | TO REVIEW | [vast-cli#409](https://github.com/vast-ai/vast-cli/pull/409) is merged; docs cover common causes, port requirements, TCP/UDP guidance, and offline/unlisted/rented possibilities | Partial: exact failed-port/protocol evidence and authoritative offline-vs-hidden state require backend/API support | +| [CON-1513](https://vastai.atlassian.net/browse/CON-1513) | TO REVIEW | Generator plus scheduled, PR, manual, and optional dispatch drift checks; the PR check passes against both source repositories | Implemented and active in PR #185 | +| [CON-1512](https://vastai.atlassian.net/browse/CON-1512) | QA Passed | [vast-cli#407](https://github.com/vast-ai/vast-cli/pull/407) is merged; docs warn that `--ignore-requirements` does not qualify a machine for verification | Implemented | +| [CON-1510](https://vastai.atlassian.net/browse/CON-1510) | TESTING | [vast-cli#408](https://github.com/vast-ai/vast-cli/pull/408) and [self-test#2](https://github.com/vast-ai/self-test/pull/2) are merged; the generated page exposes actual/required values, purpose, remediation, stable codes, and source metadata | Implemented in runtime and PR #185; Jira testing/sign-off remains | +| [CON-1502](https://vastai.atlassian.net/browse/CON-1502) | QA Passed | [self-test#4](https://github.com/vast-ai/self-test/pull/4) is merged; the generated reference exposes the validated image/platform matrix | Implemented | +| [CON-1419](https://vastai.atlassian.net/browse/CON-1419) | TO REVIEW | [vast-cli#408](https://github.com/vast-ai/vast-cli/pull/408) selects CUDA 11.8 for pre-Volta and caps Volta at CUDA 12.8; the generated page documents the rules | Implemented in runtime and PR #185; reviewer sign-off remains | + +## Implemented review corrections + +- PR #185 contains the generated Self-Test reference and its source generator; + docs PR [#145](https://github.com/vast-ai/docs/pull/145) is superseded and is + being closed rather than treated as an integration dependency. +- The `verify-self-test-reference` check passes against Vast CLI and the private + Self-Test source repository. +- The review panel links each page to its relevant epics, tickets, named owner + questions, and remaining blocker count. +- The Self-Test page now has one remaining product-fact gate: authoritative + verification queue and wait-time wording. Generated thresholds, failure + codes, dispatch checking, B300 guidance, and older-GPU selection are present. +- Host Diagnostics documents the merged `vastai dump-logs` workflow. Remaining + questions concern operations ownership, artifact policy, and evidence that + only backend or host-side systems can provide. + +## CON-1519: what exists and what the meeting must decide + +### Implemented mechanics + +- A failed `vastai self-test machine ` creates a redacted diagnostic + archive automatically unless support bundles are explicitly disabled. +- `vastai dump-logs ` creates one on demand. The caller can provide + an instance ID for API-visible instance logs and can choose the output + directory. +- The archive is created on the machine where the CLI runs. The default + directory is `/tmp`; nothing uploads it to Vast, Jira, or object storage. +- The archive is named `vast_selftest__.tar.gz` and is + written with `0600` permissions. +- Every archive records a manifest and collection errors. Self-Test output, + structured result data, and API-visible instance status/container/daemon + evidence are included when available. +- Non-JSON text/log artifacts are tail-bounded; collection commands have a + timeout; sensitive key names and explicit secrets are redacted. The user is + told to review the archive before sharing it. +- Host-local Kaalia, Docker, kernel, NVIDIA, network, and mount evidence is + opt-in with `--include-local-host-artifacts` and is useful only when the CLI + is running on the actual host. A laptop cannot collect the host's local OS + state remotely. + +### Ownership decisions still required + +| Decision | Question to answer in the meeting | Proposed starting point, not yet approved | +|---|---|---| +| Intake | Where should a host send a reviewed archive? | A restricted support-ticket attachment or approved private upload, never a public Jira/Slack channel | +| Accountable owner | Who owns the bundle after it is received? | Support Operations owns intake and case tracking | +| First triage | Who confirms scope, redaction, completeness, and failure category? | Support L1 uses a checklist and routes by evidence type | +| Diagnosis | Who diagnoses CLI, backend/daemon, and host-local failures? | CLI maintainers own schema/collection bugs; Backend/Daemon owns API/instance evidence; Host Engineering/SRE owns host-local runtime evidence | +| Retention and access | How long is the archive kept, who can access it, and who deletes it? | Security/Support Operations must approve a retention period and least-privilege access group | +| Escalation | What evidence and response are required when L1 cannot resolve it? | A routing matrix with named queues and a feedback path for new error codes/remediation | + +The implementation cannot settle this RACI by itself. To close CON-1519 +operationally, the meeting should name one accountable intake owner, approve a +transfer location and retention/access policy, and name the first diagnostic +owner for each evidence class. + +## Remaining decisions by review area + +- **Host Teams / account setup:** migration and earnings behavior, installation + key semantics, registration permissions, and billing-role behavior. +- **Machine errors / network:** complete public catalog, UI fields, clearing + behavior, exact failed-port/protocol evidence, and offline-versus-hidden state. +- **Self-Test:** verification queue and wait-time wording; CON-1519 operations + ownership and safe artifact policy. +- **Business pages:** Solutions Engineering/business review and named content + owner. +- **Review mechanics:** approve lifecycle IA/persona treatment and the remaining + product assets, then choose the merge/review sequence for PR #185. + +## Validation evidence + +- `npm run test-review-context` covers page-scoped Jira context and verifies that + the overlay exists only on the port 4000 review proxy. +- `verify-self-test-reference` passes on PR #185 and guards source/docs drift. +- Vast CLI PRs #407, #408, #409, and #410 are merged. +- Self-Test PRs #2, #3, and #4 are merged. diff --git a/api-reference/introduction.mdx b/api-reference/introduction.mdx index 6df3586c..572529c0 100644 --- a/api-reference/introduction.mdx +++ b/api-reference/introduction.mdx @@ -23,6 +23,7 @@ New to the API? Start with the [API Hello World](/api-reference/hello-world), it | [Rate limits & errors](/api-reference/rate-limits-and-errors) | Per-endpoint limits, error codes, retry guidance | | [Creating instances](/api-reference/creating-instances-with-api) | Search-and-rent flow, configuration options | | [Templates](/api-reference/creating-and-using-templates-with-api) | Template fields, creation, instance launch | +| [Notifications](/api-reference/notifications/list-notification-types) | Notification preferences and webhooks | | Endpoints | Full OpenAPI reference for every endpoint | ## Base URL diff --git a/api-reference/openapi.yaml b/api-reference/openapi.yaml index 848f2008..ea2a1b6c 100644 --- a/api-reference/openapi.yaml +++ b/api-reference/openapi.yaml @@ -4993,6 +4993,392 @@ paths: example: API requests too frequent endpoint threshold=3.0 tags: - Machines + /api/v0/notification-types/: + get: + summary: list notification types + description: List the notification types available to the authenticated user, + including their display names, contexts, topics, and default channel settings. + security: + - BearerAuth: [] + tags: + - Notifications + responses: + '200': + description: Notification types returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_types: + type: array + items: + $ref: '#/components/schemas/NotificationType' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/users/{user_id}/notification-prefs/: + get: + summary: get notification preferences + description: Read notification preferences for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + description: ID of the authenticated user. + responses: + '200': + description: Notification preferences returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + summary: update notification preferences + description: Replace notification preferences for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + description: ID of the authenticated user. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - notification_preferences + properties: + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + example: + notification_preferences: + client: + low_credit: + email: true + webhooks: false + responses: + '200': + description: Notification preferences updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/: + get: + summary: list notification webhooks + description: List notification webhooks for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + responses: + '200': + description: Webhooks returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhooks: + type: array + items: + $ref: '#/components/schemas/NotificationWebhook' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: create notification webhook + description: Create a notification webhook for one or more notification type + keys. The response includes the signing secret; store it immediately. + security: + - BearerAuth: [] + tags: + - Notifications + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationWebhookCreateRequest' + example: + name: Ops notifications + webhook_url: https://example.com/vast/webhooks + event_types: + - client:low_credit + - host:machine_offline + responses: + '200': + description: Webhook created successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhookWithSecret' + enabled_notification_preferences: + type: array + items: + type: string + example: + - client:low_credit + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/: + put: + summary: update notification webhook + description: Update a notification webhook's name, URL, or event subscriptions. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationWebhookUpdateRequest' + example: + name: Primary ops notifications + event_types: + - client:low_credit + - client:outbid + responses: + '200': + description: Webhook updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhook' + enabled_notification_preferences: + type: array + items: + type: string + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + summary: delete notification webhook + description: Delete a notification webhook. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Webhook deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/rotate-secret/: + post: + summary: rotate notification webhook secret + description: Rotate the webhook signing secret. Store the returned secret immediately. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Webhook secret rotated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhookWithSecret' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/test/: + post: + summary: test notification webhook + description: Send a test delivery to the webhook URL using the same request + format and signature headers as normal webhook delivery. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Test delivery accepted + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + msg: + type: string + example: Test webhook delivered + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/v0/instances/prepay/{id}/: put: summary: prepay instance @@ -8817,6 +9203,130 @@ components: success: type: boolean example: true + NotificationType: + type: object + properties: + key: + type: string + description: Full notification type key. + example: client:low_credit + slug: + type: string + example: low_credit + context: + type: string + enum: + - client + - host + topic: + type: string + nullable: true + example: billing + category: + type: string + example: billing + display_name: + type: string + example: Low balance notifications + default_preferences: + type: object + additionalProperties: + type: boolean + example: + email: true + webhooks: false + mandatory_email: + type: boolean + example: false + NotificationPreferences: + type: object + additionalProperties: + type: object + additionalProperties: + type: object + additionalProperties: + type: boolean + example: + client: + low_credit: + email: true + webhooks: false + NotificationWebhook: + type: object + properties: + id: + type: integer + example: 42 + user_id: + type: integer + example: 123 + name: + type: string + nullable: true + example: Ops notifications + webhook_url: + type: string + format: uri + example: https://example.com/vast/webhooks + event_types: + type: array + items: + type: string + example: + - client:low_credit + - host:machine_offline + created_at: + type: number + format: float + example: 1772490000.0 + updated_at: + type: number + format: float + example: 1772490000.0 + NotificationWebhookWithSecret: + allOf: + - $ref: '#/components/schemas/NotificationWebhook' + - type: object + properties: + webhook_secret: + type: string + description: Secret used to verify `X-Vast-Signature-256`. + example: + NotificationWebhookCreateRequest: + type: object + required: + - webhook_url + - event_types + properties: + name: + type: string + nullable: true + maxLength: 120 + webhook_url: + type: string + format: uri + maxLength: 2048 + event_types: + type: array + minItems: 1 + items: + type: string + NotificationWebhookUpdateRequest: + type: object + properties: + name: + type: string + nullable: true + maxLength: 120 + webhook_url: + type: string + format: uri + maxLength: 2048 + event_types: + type: array + minItems: 1 + items: + type: string Template: type: object properties: diff --git a/api-reference/openapi/yaml/notifications.yaml b/api-reference/openapi/yaml/notifications.yaml new file mode 100644 index 00000000..021c51b3 --- /dev/null +++ b/api-reference/openapi/yaml/notifications.yaml @@ -0,0 +1,644 @@ +openapi: 3.0.0 +info: + title: Vast.ai Notifications API + description: Manage notification types, preferences, in-app notifications, and notification webhooks. + version: 1.0.0 +servers: +- url: https://console.vast.ai + description: Production server +paths: + /api/v0/notification-types/: + get: + summary: list notification types + description: | + List the notification types available to the authenticated user, including their display names, contexts, topics, and default channel settings. + security: + - BearerAuth: [] + tags: + - Notifications + responses: + '200': + description: Notification types returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_types: + type: array + items: + $ref: '#/components/schemas/NotificationType' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/users/{user_id}/notification-prefs/: + get: + summary: get notification preferences + description: Read notification preferences for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + description: ID of the authenticated user. + responses: + '200': + description: Notification preferences returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + summary: update notification preferences + description: Replace notification preferences for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + description: ID of the authenticated user. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - notification_preferences + properties: + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + example: + notification_preferences: + client: + low_credit: + email: true + webhooks: false + responses: + '200': + description: Notification preferences updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + notification_preferences: + $ref: '#/components/schemas/NotificationPreferences' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # HIDDEN: in-app notification inbox endpoints — no console UI yet. Uncomment this block and run `npm run build-openapi` to re-enable. +# /api/v0/notifications/inbox/: +# get: +# summary: get notification inbox +# description: Read recent in-app notifications for the authenticated user. +# security: +# - BearerAuth: [] +# tags: +# - Notifications +# responses: +# '200': +# description: Inbox returned successfully +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# example: true +# notifications: +# type: array +# items: +# $ref: '#/components/schemas/InAppNotification' +# last_seen_at: +# type: number +# format: float +# nullable: true +# seen_through_at: +# type: number +# format: float +# nullable: true +# unread_count: +# type: integer +# example: 2 +# '401': +# description: Unauthorized +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Error' +# put: +# summary: mark notification inbox as seen +# description: Mark in-app notifications as seen using a timestamp returned by the inbox endpoint. +# security: +# - BearerAuth: [] +# tags: +# - Notifications +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# required: +# - seen_through_at +# properties: +# seen_through_at: +# type: number +# format: float +# description: Timestamp returned by `GET /api/v0/notifications/inbox/`. +# example: +# seen_through_at: 1772490000.123 +# responses: +# '200': +# description: Inbox marked seen successfully +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# example: true +# last_seen_at: +# type: number +# format: float +# '400': +# description: Bad Request +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Error' +# '401': +# description: Unauthorized +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Error' + /api/v0/webhooks/: + get: + summary: list notification webhooks + description: List notification webhooks for the authenticated user. + security: + - BearerAuth: [] + tags: + - Notifications + responses: + '200': + description: Webhooks returned successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhooks: + type: array + items: + $ref: '#/components/schemas/NotificationWebhook' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: create notification webhook + description: | + Create a notification webhook for one or more notification type keys. The response includes the signing secret; store it immediately. + security: + - BearerAuth: [] + tags: + - Notifications + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationWebhookCreateRequest' + example: + name: Ops notifications + webhook_url: https://example.com/vast/webhooks + event_types: + - client:low_credit + - host:machine_offline + responses: + '200': + description: Webhook created successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhookWithSecret' + enabled_notification_preferences: + type: array + items: + type: string + example: + - client:low_credit + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/: + put: + summary: update notification webhook + description: Update a notification webhook's name, URL, or event subscriptions. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationWebhookUpdateRequest' + example: + name: Primary ops notifications + event_types: + - client:low_credit + - client:outbid + responses: + '200': + description: Webhook updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhook' + enabled_notification_preferences: + type: array + items: + type: string + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + summary: delete notification webhook + description: Delete a notification webhook. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Webhook deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/rotate-secret/: + post: + summary: rotate notification webhook secret + description: Rotate the webhook signing secret. Store the returned secret immediately. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Webhook secret rotated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + webhook: + $ref: '#/components/schemas/NotificationWebhookWithSecret' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v0/webhooks/{id}/test/: + post: + summary: test notification webhook + description: Send a test delivery to the webhook URL using the same request format and signature headers as normal webhook delivery. + security: + - BearerAuth: [] + tags: + - Notifications + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: Webhook ID. + responses: + '200': + description: Test delivery accepted + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + msg: + type: string + example: Test webhook delivered + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: API key must be provided in the Authorization header + schemas: + NotificationType: + type: object + properties: + key: + type: string + description: Full notification type key. + example: client:low_credit + slug: + type: string + example: low_credit + context: + type: string + enum: + - client + - host + topic: + type: string + nullable: true + example: billing + category: + type: string + example: billing + display_name: + type: string + example: Low balance notifications + default_preferences: + type: object + additionalProperties: + type: boolean + example: + email: true + webhooks: false + mandatory_email: + type: boolean + example: false + NotificationPreferences: + type: object + additionalProperties: + type: object + additionalProperties: + type: object + additionalProperties: + type: boolean + example: + client: + low_credit: + email: true + webhooks: false + # HIDDEN: InAppNotification schema (used only by the hidden inbox endpoints). Uncomment with the inbox block above to re-enable. +# InAppNotification: +# type: object +# properties: +# event_id: +# type: string +# example: 7e9a2c4e6f9e4a24a53b77c2d8e3f0aa +# notif_type: +# type: string +# example: low_credit +# subject: +# type: string +# example: Warning - Your Vast.ai Credit Balance Is Getting Low +# message: +# type: string +# example: Your Vast.ai balance is below your configured threshold. +# timestamp: +# type: number +# format: float +# example: 1772490000.123 + NotificationWebhook: + type: object + properties: + id: + type: integer + example: 42 + user_id: + type: integer + example: 123 + name: + type: string + nullable: true + example: Ops notifications + webhook_url: + type: string + format: uri + example: https://example.com/vast/webhooks + event_types: + type: array + items: + type: string + example: + - client:low_credit + - host:machine_offline + created_at: + type: number + format: float + example: 1772490000.0 + updated_at: + type: number + format: float + example: 1772490000.0 + NotificationWebhookWithSecret: + allOf: + - $ref: '#/components/schemas/NotificationWebhook' + - type: object + properties: + webhook_secret: + type: string + description: Secret used to verify `X-Vast-Signature-256`. + example: + NotificationWebhookCreateRequest: + type: object + required: + - webhook_url + - event_types + properties: + name: + type: string + nullable: true + maxLength: 120 + webhook_url: + type: string + format: uri + maxLength: 2048 + event_types: + type: array + minItems: 1 + items: + type: string + NotificationWebhookUpdateRequest: + type: object + properties: + name: + type: string + nullable: true + maxLength: 120 + webhook_url: + type: string + format: uri + maxLength: 2048 + event_types: + type: array + minItems: 1 + items: + type: string + Error: + type: object + properties: + success: + type: boolean + example: false + error: + type: string + msg: + type: string diff --git a/docs.json b/docs.json index 71380dd3..5e5b6eae 100644 --- a/docs.json +++ b/docs.json @@ -172,6 +172,13 @@ "guides/pricing", "guides/reference/account-settings", "guides/reference/billing", + { + "group": "Notifications", + "pages": [ + "guides/reference/notifications", + "guides/reference/notification-webhooks" + ] + }, "guides/reference/keys", "guides/reference/two-factor-authentication", "guides/reference/referral-program", @@ -598,12 +605,19 @@ "host/guide-to-taxes" ] }, + { + "group": "Account", + "icon": "user", + "pages": [ + "host/account-hosting-agreement", + "host/account-security-for-hosts" + ] + }, { "group": "Set Up", "icon": "wrench", "pages": [ "host/quickstart", - "host/account-hosting-agreement", "host/hardware-prep", "host/storage-setup", "host/network-ports", @@ -616,13 +630,13 @@ "group": "Verify & List", "icon": "badge-check", "pages": [ - "host/optimization-guide", - "host/verification-stages", - "host/understanding-verification", "host/how-to-self-test", - "host/market-metrics", "host/self-test-reference", "host/pricing-your-listing", + "host/market-metrics", + "host/optimization-guide", + "host/understanding-verification", + "host/verification-stages", "host/not-in-search" ] }, @@ -632,6 +646,7 @@ "pages": [ "host/first-24-hours", "host/reliability-uptime", + "host/notifications", "host/maintenance-windows", "host/removing-recreating-machines", "host/fleet-operations", @@ -657,6 +672,7 @@ "group": "CLI", "icon": "terminal", "pages": [ + "host/cli-api-sdk", "host/cli/cleanup-machine", "host/cli/delete-machine", "host/cli/list-machine", @@ -721,6 +737,14 @@ "examples/serving-infrastructure/sglang-router-vast" ] }, + { + "group": "Notifications", + "icon": "bell", + "pages": [ + "examples/notifications/slack-webhook", + "examples/notifications/google-chat-webhook" + ] + }, { "group": "Migration guides", "icon": "arrow-right-arrow-left", diff --git a/examples/notifications/google-chat-webhook.mdx b/examples/notifications/google-chat-webhook.mdx new file mode 100644 index 00000000..92b753d7 --- /dev/null +++ b/examples/notifications/google-chat-webhook.mdx @@ -0,0 +1,330 @@ +--- +title: "Send Notifications to Google Chat" +slug: "vast-notifications-google-chat-webhook" +createdAt: "Tue Jun 30 2026 00:00:00 GMT+0000 (Coordinated Universal Time)" +updatedAt: "Tue Jun 30 2026 00:00:00 GMT+0000 (Coordinated Universal Time)" +--- + +Use this example to send Vast.ai notification webhooks into a Google Chat space. Vast.ai sends a standard notification payload, and the adapter below verifies the Vast.ai signature before converting it into the `{"text": "..."}` format Google Chat expects. + +The flow is: + +```text +Vast.ai notification -> your HTTPS webhook URL -> local adapter -> Google Chat +``` + +## Prerequisites + +- A Vast.ai API key from [Keys](https://cloud.vast.ai/manage-keys/) +- A Google Chat space where you can add an incoming webhook +- Python 3.10 or newer +- A public HTTPS URL that forwards to your local machine for testing, such as Tunnelmole, ngrok, or Cloudflare Tunnel + +## Review Notification Settings + +Open [Account Settings](https://cloud.vast.ai/account/) and review **Notification Settings**. The notification groups shown here are the same event groups you can subscribe to through the API. + + + ![Notification Settings page with Account, Billing, and Instance notification groups](/images/console-notifications-settings.png) + + +## Create a Google Chat Incoming Webhook + +In Google Chat: + +1. Create or open a space. +2. Open the space menu. +3. Select **Apps & integrations**. +4. Add an incoming webhook. +5. Copy the Google Chat webhook URL. + +Keep that URL private. It lets anyone who has it post into the space. + +## Create the Adapter + +Create a working directory and install the only Python dependency: + +```bash +mkdir vast-google-chat-notifications +cd vast-google-chat-notifications +python3 -m venv .venv +. .venv/bin/activate +pip install requests +``` + +Create `vast_google_chat_adapter.py`: + +```python +#!/usr/bin/env python3 +import hashlib +import hmac +import json +import os +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import requests + + +GOOGLE_CHAT_URL = os.environ["GOOGLE_CHAT_URL"] +VAST_WEBHOOK_SECRET = os.environ["VAST_WEBHOOK_SECRET"] +PORT = int(os.environ.get("PORT", "8787")) +MAX_SIGNATURE_AGE_SECONDS = 300 + + +def verify_vast_signature(headers, raw_body: bytes) -> bool: + timestamp = headers.get("X-Vast-Timestamp", "") + signature = headers.get("X-Vast-Signature-256", "") + + if not timestamp or not signature.startswith("sha256="): + return False + + try: + age = abs(time.time() - int(timestamp)) + except ValueError: + return False + + if age > MAX_SIGNATURE_AGE_SECONDS: + return False + + signed = timestamp.encode("utf-8") + b"." + raw_body + digest = hmac.new( + VAST_WEBHOOK_SECRET.encode("utf-8"), + signed, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(signature, f"sha256={digest}") + + +def google_chat_text(payload: dict) -> str: + subject = payload.get("subject") or "Vast.ai notification" + message = payload.get("message") or json.dumps(payload, sort_keys=True) + notif_type = payload.get("notif_type") + event_id = payload.get("event_id") + + lines = [subject, "", str(message)] + details = [] + if notif_type: + details.append(f"type={notif_type}") + if event_id: + details.append(f"event_id={event_id}") + if details: + lines.extend(["", " ".join(details)]) + return "\n".join(lines) + + +class Handler(BaseHTTPRequestHandler): + def _json(self, status: int, body: dict): + data = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/health": + self._json(200, {"ok": True}) + return + self._json(404, {"ok": False, "error": "not_found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw_body = self.rfile.read(length) + + if not verify_vast_signature(self.headers, raw_body): + self._json(401, {"ok": False, "error": "invalid_signature"}) + return + + try: + payload = json.loads(raw_body.decode("utf-8") or "{}") + except json.JSONDecodeError: + self._json(400, {"ok": False, "error": "invalid_json"}) + return + + if not isinstance(payload, dict): + self._json(400, {"ok": False, "error": "invalid_payload"}) + return + + response = requests.post( + GOOGLE_CHAT_URL, + json={"text": google_chat_text(payload)}, + timeout=10, + ) + if response.status_code >= 400: + self._json( + 502, + {"ok": False, "google_chat_status": response.status_code}, + ) + return + + print( + f"forwarded notif_type={payload.get('notif_type')} " + f"event_id={payload.get('event_id')}", + flush=True, + ) + self._json(200, {"ok": True}) + + def log_message(self, fmt, *args): + print(f"{self.address_string()} - {fmt % args}", flush=True) + + +if __name__ == "__main__": + print(f"listening on http://127.0.0.1:{PORT}", flush=True) + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +``` + +## Create the Vast.ai Webhook + +From here you use three terminals, all in the `vast-google-chat-notifications` directory: the **adapter terminal** you just used, a **control terminal** for Vast.ai API calls, and a **tunnel terminal** for `tmole`. + +Create the webhook first. The adapter needs this webhook's signing secret before it can start, and `tmole` only starts once the adapter is already listening — so you create the webhook with a temporary URL now and point it at the tunnel once the tunnel is up. + +In the **control terminal**, set your Vast.ai API key and create the webhook: + +```bash +cd vast-google-chat-notifications + +read -rsp "Vast.ai API key: " VAST_API_KEY +export VAST_API_KEY +echo + +cat > create-webhook.json < +The signing secret (`webhook_secret`) is saved in `vast-webhook.json`. Store it securely — Vast.ai returns it only when the webhook is created or its secret is rotated, not on list or update responses. + + +You can create the same webhook from the console, but the API flow is best for this example because the adapter needs the signing secret. + + + ![Create webhook modal with webhook name and webhook URL fields](/images/console-notification-webhook.png) + + +## Start the Adapter + +In the **adapter terminal** (where the virtual environment is active), load the signing secret from `vast-webhook.json`, set your Google Chat webhook URL, and start the adapter: + +```bash +export VAST_WEBHOOK_SECRET="$( + python3 -c 'import json; print(json.load(open("vast-webhook.json"))["webhook"]["webhook_secret"])' +)" + +read -rsp "Google Chat webhook URL: " GOOGLE_CHAT_URL +export GOOGLE_CHAT_URL +echo + +python3 vast_google_chat_adapter.py +``` + +The adapter prints `listening on http://127.0.0.1:8787` and listens only on `127.0.0.1`. Leave it running; the HTTPS tunnel you start next forwards public Vast.ai deliveries to that local port. + +## Expose the Adapter Over HTTPS + +Vast.ai requires an HTTPS webhook URL. This example uses `tmole`; if you use another tunnel, run the equivalent command and copy the HTTPS URL it prints. + +Because `tmole` only starts once something is listening on the port, start it now that the adapter is running. In the **tunnel terminal**: + +```bash +tmole 8787 +``` + +Copy the HTTPS URL it prints, such as `https://xxxxxx.tunnelmole.net`. + +## Point the Webhook at Your Tunnel + +In the **control terminal**, set the tunnel URL and update the webhook so Vast.ai delivers to it: + +```bash +read -rp "Public HTTPS webhook URL: " PUBLIC_WEBHOOK_URL +export PUBLIC_WEBHOOK_URL + +cat > update-webhook.json < your HTTPS webhook URL -> local adapter -> Slack +``` + +## Prerequisites + +- A Vast.ai API key from [Keys](https://cloud.vast.ai/manage-keys/) +- A Slack workspace where you can create an incoming webhook +- Python 3.10 or newer +- A public HTTPS URL that forwards to your local machine for testing, such as Tunnelmole, ngrok, or Cloudflare Tunnel + +## Review Notification Settings + +Open [Account Settings](https://cloud.vast.ai/account/) and review **Notification Settings**. The notification groups shown here are the same event groups you can subscribe to through the API. + + + ![Notification Settings page with Account, Billing, and Instance notification groups](/images/console-notifications-settings.png) + + +## Create a Slack Incoming Webhook + +Create a Slack incoming webhook for the channel that should receive Vast.ai notifications. Slack's own setup guide is here: [Sending messages using incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). + +When Slack gives you the webhook URL, keep it private. Anyone who has that URL can post into the selected channel. + +## Create the Adapter + +Create a working directory and install the only Python dependency: + +```bash +mkdir vast-slack-notifications +cd vast-slack-notifications +python3 -m venv .venv +. .venv/bin/activate +pip install requests +``` + +Create `vast_slack_adapter.py`: + +```python +#!/usr/bin/env python3 +import hashlib +import hmac +import json +import os +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import requests + + +SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"] +VAST_WEBHOOK_SECRET = os.environ["VAST_WEBHOOK_SECRET"] +PORT = int(os.environ.get("PORT", "8787")) +MAX_SIGNATURE_AGE_SECONDS = 300 + +CONSOLE = os.environ.get("VAST_CONSOLE", "https://cloud.vast.ai") +ACTION_URLS = { + "low_credit": f"{CONSOLE}/billing/", + "billing_failed": f"{CONSOLE}/billing/", + "payment_receipt": f"{CONSOLE}/billing/", + "instance_created": f"{CONSOLE}/instances/", + "instance_started": f"{CONSOLE}/instances/", + "instance_stopped": f"{CONSOLE}/instances/", + "instance_offline": f"{CONSOLE}/instances/", + "instance_online": f"{CONSOLE}/instances/", + "outbid": f"{CONSOLE}/instances/", + "upcoming_downtime": f"{CONSOLE}/instances/", + "webhook_test": CONSOLE, +} + + +def verify_vast_signature(headers, raw_body: bytes) -> bool: + timestamp = headers.get("X-Vast-Timestamp", "") + signature = headers.get("X-Vast-Signature-256", "") + + if not timestamp or not signature.startswith("sha256="): + return False + + try: + age = abs(time.time() - int(timestamp)) + except ValueError: + return False + + if age > MAX_SIGNATURE_AGE_SECONDS: + return False + + signed = timestamp.encode("utf-8") + b"." + raw_body + digest = hmac.new( + VAST_WEBHOOK_SECRET.encode("utf-8"), + signed, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(signature, f"sha256={digest}") + + +def slack_message(payload: dict) -> dict: + subject = payload.get("subject") or "Vast.ai notification" + message = payload.get("message") or json.dumps(payload, sort_keys=True) + notif_type = payload.get("notif_type") or "notification" + event_id = payload.get("event_id") + action_url = ACTION_URLS.get(notif_type, CONSOLE) + + details = [f"type={notif_type}"] + if event_id: + details.append(f"event_id={event_id}") + + return { + "text": f"{subject}\n{message}\n{action_url}\n{' '.join(details)}" + } + + +class Handler(BaseHTTPRequestHandler): + def _json(self, status: int, body: dict): + data = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/health": + self._json(200, {"ok": True}) + return + self._json(404, {"ok": False, "error": "not_found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw_body = self.rfile.read(length) + + if not verify_vast_signature(self.headers, raw_body): + self._json(401, {"ok": False, "error": "invalid_signature"}) + return + + try: + payload = json.loads(raw_body.decode("utf-8") or "{}") + except json.JSONDecodeError: + self._json(400, {"ok": False, "error": "invalid_json"}) + return + + if not isinstance(payload, dict): + self._json(400, {"ok": False, "error": "invalid_payload"}) + return + + response = requests.post( + SLACK_WEBHOOK_URL, + json=slack_message(payload), + timeout=10, + ) + if response.status_code >= 400: + self._json(502, {"ok": False, "slack_status": response.status_code}) + return + + print( + f"forwarded notif_type={payload.get('notif_type')} " + f"event_id={payload.get('event_id')}", + flush=True, + ) + self._json(200, {"ok": True}) + + def log_message(self, fmt, *args): + print(f"{self.address_string()} - {fmt % args}", flush=True) + + +if __name__ == "__main__": + print(f"listening on http://127.0.0.1:{PORT}", flush=True) + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +``` + +## Create the Vast.ai Webhook + +From here you use three terminals, all in the `vast-slack-notifications` directory: the **adapter terminal** you just used, a **control terminal** for Vast.ai API calls, and a **tunnel terminal** for `tmole`. + +Create the webhook first. The adapter needs this webhook's signing secret before it can start, and `tmole` only starts once the adapter is already listening — so you create the webhook with a temporary URL now and point it at the tunnel once the tunnel is up. + +In the **control terminal**, set your Vast.ai API key and create the webhook: + +```bash +cd vast-slack-notifications + +read -rsp "Vast.ai API key: " VAST_API_KEY +export VAST_API_KEY +echo + +cat > create-webhook.json < +The signing secret (`webhook_secret`) is saved in `vast-webhook.json`. Store it securely — Vast.ai returns it only when the webhook is created or its secret is rotated, not on list or update responses. + + +You can create the same webhook from the console, but the API flow is best for this example because the adapter needs the signing secret. + + + ![Create webhook modal with webhook name and webhook URL fields](/images/console-notification-webhook.png) + + +## Start the Adapter + +In the **adapter terminal** (where the virtual environment is active), load the signing secret from `vast-webhook.json`, set your Slack webhook URL, and start the adapter: + +```bash +export VAST_WEBHOOK_SECRET="$( + python3 -c 'import json; print(json.load(open("vast-webhook.json"))["webhook"]["webhook_secret"])' +)" + +read -rsp "Slack incoming webhook URL: " SLACK_WEBHOOK_URL +export SLACK_WEBHOOK_URL +echo + +python3 vast_slack_adapter.py +``` + +The adapter prints `listening on http://127.0.0.1:8787` and listens only on `127.0.0.1`. Leave it running; the HTTPS tunnel you start next forwards public Vast.ai deliveries to that local port. + +## Expose the Adapter Over HTTPS + +Vast.ai requires an HTTPS webhook URL. This example uses `tmole`; if you use another tunnel, run the equivalent command and copy the HTTPS URL it prints. + +Because `tmole` only starts once something is listening on the port, start it now that the adapter is running. In the **tunnel terminal**: + +```bash +tmole 8787 +``` + +Copy the HTTPS URL it prints, such as `https://xxxxxx.tunnelmole.net`. + +## Point the Webhook at Your Tunnel + +In the **control terminal**, set the tunnel URL and update the webhook so Vast.ai delivers to it: + +```bash +read -rp "Public HTTPS webhook URL: " PUBLIC_WEBHOOK_URL +export PUBLIC_WEBHOOK_URL + +cat > update-webhook.json <`** in Claude Code and **`/prompts:vast- - Install from source: + Inside Claude Code, add the marketplace and install the plugin: - ```bash - git clone https://github.com/vast-ai/vast-claude-plugin.git - claude --plugin-dir ./vast-claude-plugin + ```text + /plugin marketplace add vast-ai/vast-claude-plugin + /plugin install vastai ``` - - Marketplace install (`/plugin install vastai@…`) is coming once the plugin - is published to a Claude Code marketplace. For now, load it from source with - `--plugin-dir`. - - Then run `/vastai:setup` once to store your API key, register your SSH public key, and verify the credential. Source: [`vast-ai/vast-claude-plugin`](https://github.com/vast-ai/vast-claude-plugin). - Install from source: + In your terminal, add the marketplace and open the plugin browser: ```bash - git clone https://github.com/vast-ai/vast-codex-plugin.git - cd vast-codex-plugin - ./install.sh + codex plugin marketplace add vast-ai/vast-codex-plugin + codex plugin # plugin browser → pick "Vast.ai" → Install ``` - This installs the skills into `~/.agents/skills/` and the prompts into `~/.codex/prompts/`. Restart your Codex session, then run `/prompts:vast-setup` once. Source: [`vast-ai/vast-codex-plugin`](https://github.com/vast-ai/vast-codex-plugin). + Restart your Codex session, then run `/prompts:vast-setup` once. Source: [`vast-ai/vast-codex-plugin`](https://github.com/vast-ai/vast-codex-plugin). No slash commands — Cursor is natural-language driven. The plugin adds both skills plus an auto-attach rule that points Cursor at the renter skill when you edit infrastructure files (`*.tf`, `*.yaml`, `infra/`, `deploy/`). - In Cursor 2.5+, run the install command in the editor: + Install it from the Cursor Dashboard → **Settings → Plugins → Team Marketplaces → Import**, then paste: ```text - /add-plugin vastai + https://github.com/vast-ai/vast-cursor-plugin ``` - Or clone and run `install.sh` to install ahead of marketplace acceptance. Then say *"Configure my Vast.ai CLI."* in Agent chat to walk through setup. Source: [`vast-ai/vast-cursor-plugin`](https://github.com/vast-ai/vast-cursor-plugin). + Then say *"Configure my Vast.ai CLI."* in Agent chat to walk through setup. Source: [`vast-ai/vast-cursor-plugin`](https://github.com/vast-ai/vast-cursor-plugin). diff --git a/guides/instances/choosing/reserved-instances.mdx b/guides/instances/choosing/reserved-instances.mdx index e42ce0c8..abf2b3b1 100644 --- a/guides/instances/choosing/reserved-instances.mdx +++ b/guides/instances/choosing/reserved-instances.mdx @@ -95,14 +95,14 @@ You can extend your reservation at any time: ## Refunds -You can cancel (destroy) a reserved / prepaid instance to get part of your deposit back. Refund = Remaining deposit **minus** total discount already received. +You can cancel (destroy) a reserved / prepaid instance to get part of your deposit back. When you reserve a machine, you get a discounted rate upfront. If you cancel early, we claw back the discount you already received, plus a **5% fee**. **Example:** - On-demand: \$1/hr → \$720/month - Reserved (1 month): \$576/month - Cancel immediately → Refund = \$576 -- Cancel after 15 days → Remaining = \$288 → Refund = \$216 (after discount penalty) +- Cancel after 15 days → Remaining = \$288 → Refund = ~\$212 (after discount clawback + 5% fee) - Cancel at the end → Refund = \$0 You will see the refund on the Billing page -\> Invoices table. @@ -137,7 +137,7 @@ Yes, you can extend it anytime via the same discount badge in the Instances page ### What happens if I cancel / delete a reserved instance early? -You'll receive a partial refund of your unused pre-paid balance, minus the total discount received so far. The refund amount will be displayed in the delete instance modal and will also appear on the Billing page after you delete the instance. +You'll receive a partial refund of your unused pre-paid balance, minus the total discount received so far plus a 5% fee. The refund amount will be displayed in the delete instance modal and will also appear on the Billing page after you delete the instance. ![image.png](/images/image.png) diff --git a/guides/reference/account-settings.mdx b/guides/reference/account-settings.mdx index ee469f94..4190ee9f 100644 --- a/guides/reference/account-settings.mdx +++ b/guides/reference/account-settings.mdx @@ -106,9 +106,11 @@ When you are finished editing your environment variables, make sure you select t ## Notification Settings -You can subscribe or unsubscribe from our email newsletter by selecting or unselecting this checkbox in the Notification Settings section. +Use Notification Settings to choose which account, billing, and instance events reach you by email, in the console, or through webhooks. See [Notifications](/guides/reference/notifications) for the full setup guide. -![](/images/console-setting-3.webp) + + ![Notification Settings page with Account, Billing, and Instance notification groups](/images/console-notifications-settings.png) + ## Cloud Connection diff --git a/guides/reference/notification-webhooks.mdx b/guides/reference/notification-webhooks.mdx new file mode 100644 index 00000000..71acfec4 --- /dev/null +++ b/guides/reference/notification-webhooks.mdx @@ -0,0 +1,181 @@ +--- +title: "Notification Webhooks" +sidebarTitle: "Webhooks" +description: "Send Vast.ai notification events to your own HTTPS endpoint for automation, monitoring, and audit workflows." +"canonical": "/guides/reference/notification-webhooks" +--- + +Notification webhooks deliver Vast.ai notification events to an HTTPS endpoint you operate. They are grouped under the notification system because they use the same notification types and preferences as email notifications. + +Use webhooks when a notification should become an action: update your dashboard when an instance starts, pause automation when a low-balance event fires, or trigger cleanup when an instance is outbid. The same mechanics apply to host events — see [Host Notifications](/host/notifications). + +## How Webhooks Fit Into Notifications + +A webhook subscribes to one or more notification type keys. When a matching event is produced and the webhook channel is enabled for that notification type, Vast.ai sends a signed `POST` request to your webhook URL. + +Subscribing a webhook to an event automatically turns on the webhook channel for that event in your notification preferences. + +## Create a Webhook in the Console + +1. Open [Account Settings](https://cloud.vast.ai/account/). +2. Go to **Notification Settings**. +3. Select the notification events you want to send to a webhook. +4. Click **Create webhook**. +5. Enter a webhook name and an HTTPS URL. +6. Click **Create**, then click **Save** on the notification settings form. + +Existing webhooks appear below the notification groups. You can edit the name or URL, delete the webhook, or unsubscribe the webhook from an individual event. + + + ![Create webhook modal with webhook name and webhook URL fields](/images/console-notification-webhook.png) + + + +Webhook changes are saved when you save the notification settings form. If you close the page before saving, the form changes are not persisted. + + +## Manage Webhooks With the API + +This guide covers the delivery behavior you need to integrate safely — signing, retries, limits, and the receiver pattern. The API calls themselves (request bodies, response schemas, status codes) are documented in the API reference: + +| Task | API reference | +| --- | --- | +| Discover subscribable event keys | [List notification types](/api-reference/notifications/list-notification-types) | +| Create a webhook | [Create notification webhook](/api-reference/notifications/create-notification-webhook) | +| List your webhooks | [List notification webhooks](/api-reference/notifications/list-notification-webhooks) | +| Update a webhook | [Update notification webhook](/api-reference/notifications/update-notification-webhook) | +| Delete a webhook | [Delete notification webhook](/api-reference/notifications/delete-notification-webhook) | +| Rotate the signing secret | [Rotate notification webhook secret](/api-reference/notifications/rotate-notification-webhook-secret) | +| Send a test delivery | [Test notification webhook](/api-reference/notifications/test-notification-webhook) | + + +The signing secret is returned only when a webhook is created and when the secret is rotated — not on list or update responses. Store it immediately. + + + +The test endpoint sends a `webhook_test` event with the same request format and signature headers as a real delivery. It is not retried. + + +## Webhook Limits and Validation + +| Rule | Details | +| --- | --- | +| Maximum webhooks | 4 per user | +| URL scheme | `https://` only when creating or updating a webhook | +| URL length | 2048 characters maximum | +| Endpoint host | Must include a hostname | +| Delivery target | Must resolve to a public IP address; localhost and private network targets are rejected at delivery time | +| Redirects | Redirect responses are treated as permanent delivery failures | +| Name | Optional through the API, 120 characters maximum, no control characters | +| Events | `event_types` must contain at least one valid event key | + +Use full notification type keys such as `client:low_credit` and `client:outbid`. Because a similar event can exist for both renters and hosts, the full key — including its `client:` or `host:` prefix — avoids ambiguity. Host webhooks use `host:` keys, documented in [Host Notifications](/host/notifications). + +## Event Payload + +Vast.ai sends a JSON `POST` request: + +```json +{ + "event_id": "7e9a2c4e6f9e4a24a53b77c2d8e3f0aa", + "user_id": 123, + "notif_type": "low_credit", + "subject": "Warning - Your Vast.ai Credit Balance Is Getting Low", + "message": "Your Vast.ai balance is below your configured threshold.", + "timestamp": 1772490000.123 +} +``` + + +The payload's `notif_type` is the short slug (for example `low_credit`), without the `client:` or `host:` context prefix used in subscription keys. If you subscribe one webhook to both a `client:` and a `host:` variant of the same event, use a dedicated webhook per context to tell them apart reliably. + + +Headers: + +| Header | Description | +| --- | --- | +| `Content-Type: application/json` | Payload format | +| `X-Vast-Event-Id` | Stable event ID. Use this to deduplicate retries. | +| `X-Vast-Delivery-Attempt` | 1 for the first attempt, then increments on retries. | +| `X-Vast-Timestamp` | Integer Unix timestamp used in the signature input. | +| `X-Vast-Signature-256` | `sha256=` plus the HMAC-SHA256 signature. | + + +The payload's `timestamp` remains a floating-point event timestamp. Signature verification uses the integer timestamp from `X-Vast-Timestamp`. + + +## Verify Signatures + +Verify every request before acting on it. Vast.ai signs the exact request body with your `webhook_secret`. + +The signature input is: + +```text +. +``` + +Python example: + +```python +import hmac +import hashlib +import time + + +def verify_vast_signature(headers, raw_body: bytes, webhook_secret: str) -> bool: + timestamp = headers.get("X-Vast-Timestamp", "") + signature = headers.get("X-Vast-Signature-256", "") + + if not timestamp or not signature.startswith("sha256="): + return False + + # Reject old requests to reduce replay risk. + try: + age = abs(time.time() - int(timestamp)) + except ValueError: + return False + + if age > 300: + return False + + signed = timestamp.encode("utf-8") + b"." + raw_body + expected = hmac.new( + webhook_secret.encode("utf-8"), + signed, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(signature, f"sha256={expected}") +``` + +Do not parse and re-serialize JSON before verification. Use the raw request body bytes exactly as received. + +## Retry Behavior + +Return a `2xx` status only once you have safely accepted the event — that is, verified the signature and enqueued it. + +| Your response | Vast.ai behavior | +| --- | --- | +| `2xx` | Delivery is successful | +| `3xx` | Permanent failure; redirects are not followed | +| `400`-`499` except `408` and `429` | Permanent failure | +| `408`, `429`, or `5xx` | Retryable failure | +| Timeout or connection error | Retryable failure | + +Webhook delivery uses a 10 second request timeout. Design receivers to do minimal work in the request path: verify the signature, enqueue the event, return `2xx`, then process asynchronously. + + +Webhook delivery is at-least-once. Store `event_id` and ignore duplicates before triggering side effects. + + +## Recommended Receiver Pattern + +1. Require `POST`. +2. Read the raw request body. +3. Verify `X-Vast-Signature-256`. +4. Reject stale timestamps. +5. Deduplicate by `event_id`. +6. Enqueue the event in your own system. +7. Return `204` or another `2xx` response quickly. + +This keeps the Vast.ai delivery path fast and gives your system control over downstream retries, paging, and processing. diff --git a/guides/reference/notifications.mdx b/guides/reference/notifications.mdx new file mode 100644 index 00000000..b83b6bff --- /dev/null +++ b/guides/reference/notifications.mdx @@ -0,0 +1,86 @@ +--- +title: "Notifications" +sidebarTitle: "Notifications" +description: "Choose which Vast.ai account, billing, and instance events reach you by email or through webhooks." +"canonical": "/guides/reference/notifications" +--- + +import NotificationChannels from '/snippets/notifications/channels.mdx'; + +Notifications let you decide which Vast.ai events you want to be told about, and where those messages go. A renter may only need low-balance and instance lifecycle alerts by email. An automated platform can subscribe to the same events through webhooks and route them into its own incident, billing, or orchestration system. + +The notification system is shared across the web console and the API. The console gives you a settings page for choosing events and destinations. The API gives developers access to the same notification types, preferences, and webhook tools. + + +This page covers renter notifications. If you also provide machines on Vast.ai, see [Host Notifications](/host/notifications) for host machine, verification, and maintenance events. + + +## Where to Find Notification Settings + +Open [Account Settings](https://cloud.vast.ai/account/) and go to **Notification Settings**. + + + ![Notification Settings page with Account, Billing, and Instance notification groups](/images/console-notifications-settings.png) + + +The page groups events by the part of Vast.ai they affect: + +| Group | What it covers | +| --- | --- | +| **Account** | Email verification, password resets, email changes, team invitations, and similar account events | +| **Billing** | Low balance, payment receipts, billing failures, and other payment events | +| **Instance** | Instance creation, startup, stops, resumes, outbid events, offline/online state, contract end dates, downtime, and disk warnings | + +Some events are only shown when they apply to your account. For example, team-invitation events only appear if you belong to a team. + + + +## Update Notifications in the Console + +1. Open **Account Settings**. +2. Go to **Notification Settings**. +3. Review each Account, Billing, and Instance section. +4. Turn off email for any optional event you do not want in your inbox. +5. For low-balance notifications, set the credit threshold that should trigger the warning. +6. Select events that should be sent to a webhook, then create or edit the webhook destination. +7. Click **Save**. + + +Set your low-balance threshold below your autobilling threshold. That gives you an early warning if an automatic charge fails before your balance reaches zero. + + +## Webhooks + +Webhooks are part of the notification system. They use the same event catalog and preferences as email notifications, but deliver events to an HTTPS endpoint that you operate. + +Use webhooks when you want to: + +- Trigger cleanup or orchestration when an instance changes state. +- Mirror billing or account events into your own tools. +- Build an audit trail outside the Vast console. + +See [Notification Webhooks](/guides/reference/notification-webhooks) for setup, payloads, signing, retries, and API examples. + +## Notification Type Keys + +Notification types are identified by a `key` with a context prefix. Renter events use the `client:` prefix, such as `client:low_credit` or `client:outbid`. + +Use the full `key` when subscribing webhooks or updating preferences. Because a similar event can exist for both renters and hosts, the full key avoids ambiguity. For the complete list of types, display names, and default channel settings, call [`GET /notification-types/`](/api-reference/notifications/list-notification-types). + +{/* HIDDEN: In-App Inbox section — no console notification inbox UI yet; restore when it ships. Original text: "The console can show an in-app notification inbox for supported events. The inbox holds a limited number of recent notifications, and older entries drop off over time. It is useful for status and workflow events, but email and webhooks are the durable channels for messages you need to retain outside the console. To read or mark the inbox programmatically, see /api-reference/notifications/get-notification-inbox." */} + +## Programmatic Access + +Building your own notification settings UI or automation? The full set of notification, preference, and webhook endpoints is documented in the [Notifications API](/api-reference/notifications/list-notification-types). + +## Good Defaults + +Start with this setup: + +| If you are... | Recommended setup | +| --- | --- | +| Renting GPU instances manually | Keep low balance, outbid, downtime, and contract end date emails enabled | +| Running automated workloads | Add a webhook for instance lifecycle, downtime, outbid, and billing events | +| Building your own platform on Vast.ai | Use notification type keys, verify webhook signatures, and deduplicate by `event_id` | + +Review the settings periodically, especially after enabling autobilling or connecting a new automation workflow. diff --git a/host/account-hosting-agreement.mdx b/host/account-hosting-agreement.mdx index c8e5f150..10f24e3a 100644 --- a/host/account-hosting-agreement.mdx +++ b/host/account-hosting-agreement.mdx @@ -1,5 +1,5 @@ --- -title: "Account & Hosting Agreement" +title: "Host Account and Agreement" sidebarTitle: "Account & Agreement" description: "Host account conversion and agreement flow." "canonical": "/host/account-hosting-agreement" @@ -25,6 +25,14 @@ If a business or fleet needs multiple operators, use a dedicated host team accou Once your host account is created, open the [host setup page](https://cloud.vast.ai/host/setup/). There is a link in the first paragraph to the hosting agreement. Read through the agreement. Once you accept, your account is converted to a hosting account, and a Machines link appears in the navigation. Your account can now list machines that are running the daemon software. + + ![Host console sidebar with Machines, Market Stats, Create Job, and Setup under Hosting](/images/host-console-host-navigation.webp) + + + + ![Host Machines compact list view](/images/host-machines-list.webp) + + The setup page is the canonical place for the current agreement link and generated install command. Do not rely on copied buttons, screenshots, or old setup instructions. @@ -34,6 +42,10 @@ Your account must be enabled for hosting and the hosting agreement must be accep If the account still behaves like a client account, confirm that you are signed into the intended host account and that the agreement flow completed. + + ![Expanded host machine health and listing status](/images/host-machine-health-status.webp) + + ## What if I accepted the agreement but still see a client account? @@ -48,5 +60,6 @@ Copy the install command only from the official setup page while signed into the | Topic | Read next | | --- | --- | | First-time setup path | [Hosting Quickstart](/host/quickstart) | +| Host account security | [Host Account Security](/host/account-security-for-hosts) | | Installing the host software | [Installing Host Software](/host/installing-host-software) | | Agreement reference | [Hosting Agreement](/host/hosting-agreement) | diff --git a/host/account-security-for-hosts.mdx b/host/account-security-for-hosts.mdx new file mode 100644 index 00000000..dd36b28c --- /dev/null +++ b/host/account-security-for-hosts.mdx @@ -0,0 +1,63 @@ +--- +title: "Host Account Security" +sidebarTitle: "Security" +description: "Host-specific pointers for 2FA, API keys, and teams." +"canonical": "/host/account-security-for-hosts" +personas: + - pro-operator + - headless-operator + - business-owner + - hobbyist +--- + +
All host personas
+ +Use this page as the host-side map for account and access topics that live in the general Vast docs. + +Hosts use account credentials and API keys for setup, self-test, machine management, team operations, and automation. This page does not replace the canonical account docs; it points you to them with host-specific context. + +## Two-Factor Authentication + +Enable two-factor authentication on any account that can manage hosted machines, billing, payouts, API keys, or team roles. + +This is especially important for: + +- host accounts that own machines +- team owners and machine operators +- accounts that can create or rotate API keys +- accounts that can view earnings, invoices, or payout history + +For setup, recovery codes, SMS, authenticator apps, and troubleshooting, see [Two-Factor Authentication](/guides/reference/two-factor-authentication). + +## API Keys + +Hosts need API keys for CLI and SDK workflows such as self-test, listing and unlisting machines, pricing changes, maintenance windows, diagnostics, and fleet scripts. + +Create keys from the account context that owns the machines. If you use teams, create automation keys while operating in the intended team context rather than a personal account context. + +Treat host automation keys like production secrets: + +- scope permissions where possible +- store keys outside shell history and public scripts +- rotate keys if they are exposed +- remove keys that are no longer used + +For key creation, reset, deletion, and the Keys page, see [Keys](/guides/reference/keys). For CLI setup, see [CLI Authentication](/cli/authentication). + +## Teams For Host Operations + +Use a team when more than one person manages a hosting operation or when a business needs shared ownership and role-based access. + +The general Teams docs cover team creation, invitations, roles, and the Context Switcher. Start with [Teams Quickstart](/guides/teams/teams-quickstart). + +Before registering or managing machines for a team, confirm that you are operating in the intended account context. Machine operations, API keys, earnings, invoices, and payout settings should belong to the account or team that owns the hosted machines. + +## Related Pages + +| Topic | Read next | +| --- | --- | +| Host account setup | [Host Account and Agreement](/host/account-hosting-agreement) | +| CLI, API, and SDK for hosts | [Host CLI/API/SDK](/host/cli-api-sdk) | +| General key management | [Keys](/guides/reference/keys) | +| Two-factor authentication | [Two-Factor Authentication](/guides/reference/two-factor-authentication) | +| Teams setup | [Teams Quickstart](/guides/teams/teams-quickstart) | diff --git a/host/cli-api-sdk.mdx b/host/cli-api-sdk.mdx new file mode 100644 index 00000000..0475aca4 --- /dev/null +++ b/host/cli-api-sdk.mdx @@ -0,0 +1,80 @@ +--- +title: "Host CLI/API/SDK" +sidebarTitle: "CLI/API/SDK Intro" +description: "How hosts use the CLI, API, and SDK for self-test, machine management, pricing, maintenance, diagnostics, and fleet operations." +"canonical": "/host/cli-api-sdk" +personas: + - pro-operator + - headless-operator + - business-owner +--- + +
Pro OperatorHeadless / DCBusiness
+ +Hosts can use the Vast CLI, REST API, and Python SDK to operate machines without relying only on the console. + +This page is a host-specific orientation page. It links to the canonical CLI, API, and SDK setup docs instead of duplicating their install, authentication, and rate-limit details. + +## Start With Authentication + +Install and authenticate the CLI before using host commands: + +- [CLI Hello World](/cli/hello-world) +- [CLI Authentication](/cli/authentication) +- [CLI Rate Limits](/cli/rate-limits) + +For Python automation, start with: + +- [SDK Quickstart](/sdk/python/quickstart) +- [SDK Authentication](/sdk/python/authentication) +- [SDK Rate Limits](/sdk/python/rate-limits) + +For direct REST API integrations, start with: + +- [API Introduction](/api-reference/introduction) +- [API Authentication](/api-reference/authentication) +- [API Rate Limits and Errors](/api-reference/rate-limits-and-errors) + +Use an API key from the account or team that owns the hosted machines. For host-specific key guidance, see [Host Account Security](/host/account-security-for-hosts). + +## Common Host Workflows + +| Workflow | CLI reference | SDK reference | +| --- | --- | --- | +| Check machine state | [show machines](/host/cli/show-machines), [show machine](/host/cli/show-machine) | [show_machines](/host/sdk/show-machines), [show_machine](/host/sdk/show-machine) | +| List or unlist machines | [list machines](/host/cli/list-machines), [unlist machine](/host/cli/unlist-machine) | [list_machines](/host/sdk/list-machines), [unlist_machine](/host/sdk/unlist-machine) | +| Run self-test | [self-test machine](/host/cli/self-test-machine) | [self_test_machine](/host/sdk/self-test-machine) | +| Schedule maintenance | [schedule maint](/host/cli/schedule-maint), [cancel maint](/host/cli/cancel-maint), [show maints](/host/cli/show-maints) | [schedule_maint](/host/sdk/schedule-maint), [cancel_maint](/host/sdk/cancel-maint), [show_maints](/host/sdk/show-maints) | +| Adjust pricing | [set min-bid](/host/cli/set-min-bid) | [set_min_bid](/host/sdk/set-min-bid) | +| Configure default jobs | [set defjob](/host/cli/set-defjob), [remove defjob](/host/cli/remove-defjob) | [set_defjob](/host/sdk/set-defjob), [remove_defjob](/host/sdk/remove-defjob) | +| Repair or clean up | [cleanup machine](/host/cli/cleanup-machine), [defrag machines](/host/cli/defrag-machines) | [cleanup_machine](/host/sdk/cleanup-machine), [defrag_machines](/host/sdk/defrag-machines) | +| Market context | [metrics gpu](/host/cli/metrics-gpu), [metrics gpu-trends](/host/cli/metrics-gpu-trends), [metrics gpu-locations](/host/cli/metrics-gpu-locations) | Use the general SDK/API reference where available. | + +## Day-To-Day Host Usage + +Most hosts use the CLI or SDK for: + +- rerunning self-test after a fix +- checking whether machines are listed, rented, or reporting errors +- listing, unlisting, or repricing machines +- scheduling and canceling maintenance +- cleaning stale storage +- operating several machines from one workstation + +For examples that operate across many machines, see [Fleet Operations](/host/fleet-operations). + +## Automation Notes + +Use `--raw` for scripts so commands return structured output where supported. Use `--retry N` for unattended workflows that may hit transient rate limits. + +Do not paste API keys into shared scripts, tickets, screenshots, or public channels. If a key is exposed, rotate it from the [Keys page](/guides/reference/keys). + +## Related Pages + +| Topic | Read next | +| --- | --- | +| Host account and keys | [Host Account Security](/host/account-security-for-hosts) | +| Self-test workflow | [How to Self-Test](/host/how-to-self-test) | +| Multi-machine scripts | [Fleet Operations](/host/fleet-operations) | +| Full CLI host reference | [Host CLI commands](/host/cli/show-machines) | +| Full SDK host reference | [Host SDK methods](/host/sdk/show-machines) | diff --git a/host/common-errors-diagnostics.mdx b/host/common-errors-diagnostics.mdx index d56b7881..a50577ca 100644 --- a/host/common-errors-diagnostics.mdx +++ b/host/common-errors-diagnostics.mdx @@ -73,6 +73,20 @@ Bundles can include: - `instance/container.log` - `instance/daemon.log` +To create a bundle without rerunning self-test, use the merged CLI helper: + +```bash +vastai dump-logs +``` + +Run it on the actual host with `--include-local-host-artifacts` only when host OS, kaalia, kernel, Docker, or mount evidence is needed: + +```bash +vastai dump-logs --include-local-host-artifacts +``` + +The flag is opt-in because a laptop or other remote CLI cannot safely collect files from the Vast host. Review the redacted archive before sharing it. See [Self-Test Reference: Diagnostic Bundles](/host/self-test-reference#diagnostic-bundles) for contents, size caps, and collection boundaries. +
## Host Service Snapshot @@ -99,6 +113,13 @@ Services should be `active`, logs should not show restart loops or repeated fata Use these commands to collect GPU, PCIe, AER, Xid, driver, and Docker GPU-injection evidence after an exact error has pointed you toward GPU or runtime health. For error meanings and first-response guidance, use [Machine Error Reference](/host/machine-errors). +Coming from an older error deep link? Jump straight to the canonical entry: + +- `bad bandwidthtest2` → [Machine Errors: Bad Bandwidthtest2](/host/machine-errors#bad-bandwidthtest2) +- PCIe, AER, or Xid issues → [Machine Errors: GPU PCIe Issue](/host/machine-errors#gpu-pcie-issue) +- CDI device injection failures → [Machine Errors: CDI Device Injection](/host/machine-errors#cdi-device-injection) +- `nvidia-smi` failures, NVML mismatch, or a GPU falling off the bus → [Machine Errors: GPU Missing Or Unhealthy](/host/machine-errors#gpu-missing-or-unhealthy) + - **NVRM**: NVIDIA kernel driver messages. - **Xid**: NVIDIA GPU fault, reset, or error codes. - **PCIe**: the bus/link between the GPU, motherboard, and CPU. diff --git a/host/common-host-questions.mdx b/host/common-host-questions.mdx index d75e6f86..2e76bc27 100644 --- a/host/common-host-questions.mdx +++ b/host/common-host-questions.mdx @@ -14,6 +14,8 @@ personas: Use this page to route common host questions to the page that owns the answer. +If your machine is listed but renters cannot find it in marketplace search, start with [Why Isn't My Machine in Search?](/host/not-in-search). + ## Getting Started | Question | Read | @@ -48,6 +50,7 @@ Use this page to route common host questions to the page that owns the answer. | How do I test ports from outside my LAN? | [Network & Ports](/host/network-ports#test-ports-outside-lan) | | What does self-test check? | [How to Self-Test](/host/how-to-self-test#what-the-self-test-checks) | | What does `vericode=8` mean? | [Glossary](/host/glossary#vericode) and [Machine Error Reference](/host/machine-errors) | +| Why isn't my machine showing in marketplace search? | [Why Isn't My Machine in Search?](/host/not-in-search) | | Why is my machine not found, not rentable, or not in search? | [Why Isn't My Machine in Search?](/host/not-in-search) | | How does verification work? | [Understanding Verification](/host/understanding-verification) | diff --git a/host/fleet-operations.mdx b/host/fleet-operations.mdx index c5aa915a..58c92ca7 100644 --- a/host/fleet-operations.mdx +++ b/host/fleet-operations.mdx @@ -10,7 +10,7 @@ personas:
Headless / DCBusiness
-Use the CLI or SDK to manage many machines from one workstation. +Use the CLI or SDK to manage many machines from one workstation. For setup, authentication, and the host command map, see [Host CLI/API/SDK](/host/cli-api-sdk). If multiple people manage the fleet, run the machines under the intended team context and assign machine permissions deliberately. See [Host Teams](/host/host-teams). @@ -96,7 +96,10 @@ To decommission, unlist first, honor remaining rental end dates, then delete. Se | Topic | Read next | | --- | --- | | Headless install | [Headless Install](/host/headless-install) | +| Datacenter verification | [Datacenter Status](/host/datacenter-status) | +| Host CLI/API/SDK | [Host CLI/API/SDK](/host/cli-api-sdk) | | Maintenance | [Maintenance Windows](/host/maintenance-windows) | | Pricing | [Pricing Your Listing](/host/pricing-your-listing) | | Team operations | [Host Teams](/host/host-teams) | +| Account security and keys | [Host Account Security](/host/account-security-for-hosts) | | SDK automation | [SDK reference](/host/sdk/show-machines) | diff --git a/host/hosting-overview.mdx b/host/hosting-overview.mdx index bfec75a0..1ee79a60 100644 --- a/host/hosting-overview.mdx +++ b/host/hosting-overview.mdx @@ -28,9 +28,13 @@ Vast does not provide setup support for getting a machine working. For community Create a dedicated host account from the [Vast host setup page](https://cloud.vast.ai/host/setup/). That flow links to the hosting agreement and starts host onboarding, including access to the Machines area after the agreement is accepted. -After signup, use this docs path for hardware, storage, networking, install, and self-test details. For account conversion or Machines-tab troubleshooting, see [Account & Hosting Agreement](/host/account-hosting-agreement). +After signup, use this docs path for hardware, storage, networking, install, and self-test details. For account conversion or Machines-tab troubleshooting, see [Host Account and Agreement](/host/account-hosting-agreement). -For setup, follow: +Before using API keys, CLI commands, SDK automation, or team access for hosting, review [Host Account Security](/host/account-security-for-hosts). + +New to hosting? The [Hosting Quickstart](/host/quickstart) walks this same path as one numbered journey. Not sure hosting fits your hardware or expectations? Start with [Is Vast for Me?](/host/persona-decision-guide). + +For direct access to each setup topic: 1. [Supported Hardware](/host/supported-hardware) 2. [Hardware Prep](/host/hardware-prep) diff --git a/host/how-to-self-test.mdx b/host/how-to-self-test.mdx index e8ae81d6..49b7437f 100644 --- a/host/how-to-self-test.mdx +++ b/host/how-to-self-test.mdx @@ -19,8 +19,8 @@ Passing self-test makes a machine eligible for verification. It does not guarant ## Before You Run It -- Install the [Vast CLI](/cli/hello-world). -- Set the API key for the host-enabled account that owns the machine. +- Install the [Vast CLI](/cli/hello-world). For host-oriented command guidance, see [Host CLI/API/SDK](/host/cli-api-sdk). +- Set the API key for the host-enabled account that owns the machine. For host key guidance, see [Host Account Security](/host/account-security-for-hosts). - List the machine. - Make sure no client is actively renting it. - Run from outside the host LAN when testing ports, such as a cloud VM or mobile connection. Same-LAN tests can be confused by NAT hairpinning. @@ -43,9 +43,11 @@ Before renting the temporary diagnostic instance, the CLI checks whether the mac - Direct ports are at least 3 ports per listed GPU. - PCIe bandwidth is greater than 2.85 GB/s. - Upload/download bandwidth meet the VRAM-scaled requirement. -- GPU VRAM is greater than 7 GiB per GPU. +- GPU VRAM is greater than 7 GB per GPU. - System RAM is close to total GPU VRAM, capped around 2 TB for very high-VRAM machines. +These are the same gates as the platform verification requirements. The canonical list lives in [Verification Stages: requirements](/host/verification-stages#additional-requirements-for-verification); if the numbers here ever differ, that page wins. + Inside the temporary self-test image, checks include: - HTTPS progress endpoint reachability. @@ -63,6 +65,14 @@ Hyperthreads and logical CPUs do not count as physical cores. ## Run The Test + + ![Test machine action in the Machines list](/images/host-self-test-machine-menu.webp) + + + + ![Self-Test confirmation dialog](/images/host-self-test-status.webp) + + Run: ```bash diff --git a/host/installing-host-software.mdx b/host/installing-host-software.mdx index 220d2614..84b89707 100644 --- a/host/installing-host-software.mdx +++ b/host/installing-host-software.mdx @@ -19,7 +19,7 @@ Install from the official Vast host setup flow, then confirm the daemon, Docker ## Before You Install -Complete [Hardware Prep](/host/hardware-prep), [Storage Setup](/host/storage-setup), and [Network & Ports](/host/network-ports). Your account must be host-enabled and the hosting agreement accepted; see [Account & Hosting Agreement](/host/account-hosting-agreement). +Complete [Hardware Prep](/host/hardware-prep), [Storage Setup](/host/storage-setup), and [Network & Ports](/host/network-ports). Your account must be host-enabled and the hosting agreement accepted; see [Host Account and Agreement](/host/account-hosting-agreement).
## Install Command @@ -30,6 +30,10 @@ The command can include an account-specific machine installation key. Do not reu For team-owned hosts, switch into the intended team context before opening the setup page or copying the command. See [Host Teams](/host/host-teams#install-in-the-right-context). + + ![Host setup page with the Install Manager step selected and setup notes visible](/images/host-setup-install-manager.webp) + + ## Host Installer Wizard @@ -98,6 +102,18 @@ Common causes: Refresh the setup page, copy a fresh command, confirm host account access, and rerun the wizard. Use the wizard error, installer log, and daemon logs to decide what to fix. + +### Daemon identify or API 4xx errors + +If the installer or daemon logs show an HTTP 4xx while registering or identifying the machine, work through these causes in order: + +1. **Truncated or mangled setup key.** Always copy the full command with the setup page's copy button; a partial paste fails authentication. Get a fresh command and rerun. +2. **Expired setup key.** Setup commands are single-use and expire. Refresh the setup page and copy a fresh command — do not rerun an old one from shell history. +3. **Account not converted to a host account.** The API rejects machines from client-only accounts. See [Host Account and Agreement](/host/account-hosting-agreement#agreement-stuck). +4. **Stale installer or retired endpoint.** An old downloaded installer can call endpoints that no longer exist. Re-download the installer from the setup page instead of reusing a cached copy. + +If the log shows a local driver, Docker, or cgroup error rather than an API 4xx, use the general causes above and [Host Diagnostics](/host/common-errors-diagnostics#logs). + ## Logs Check `vast_host_install.log`, the `vastai.service` systemd unit, and the kaalia logs. For log locations and diagnostic bundles, see [Host Diagnostics](/host/common-errors-diagnostics#logs). diff --git a/host/machine-errors.mdx b/host/machine-errors.mdx index 6826e585..caed7cf6 100644 --- a/host/machine-errors.mdx +++ b/host/machine-errors.mdx @@ -24,6 +24,10 @@ Errors have different impact levels. Some de-verify the whole machine and take i Resolved errors may take time to clear because the host daemon and platform need to observe healthy state again. Do not assume immediate clearing after a single fix or reboot. + + ![Machine problem reports list](/images/host-machine-problem-reports.webp) + + ## Listing Impact | Impact | What happens | Examples | What to do | @@ -220,7 +224,7 @@ nvidia-smi -q | grep -i -A 2 Fabric nvidia-smi topo -m ``` -See [Self-Test Reference: nccl_failed](/host/self-test-reference#nccl-failed). +Canonical entry (including NVSwitch/Fabric Manager checks): [Self-Test Reference: nccl_failed](/host/self-test-reference#nccl-failed). ## XFS Project Quota Error @@ -253,7 +257,7 @@ Do not manually delete renter data. For expired or deleted rentals that did not Start in the Machines page for the host account. Confirm the machine is listed, online, not currently rented in a way that hides the expected offer, has active offers, and shows required details such as ports, RAM, upload speed, and download speed. Then confirm the CLI is authenticated to the same host-enabled account. -See [Self-Test Reference: machine not rentable](/host/self-test-reference#machine-not-rentable) and [Not in Search](/host/not-in-search). +Canonical entry: [Self-Test Reference: machine not rentable](/host/self-test-reference#machine-not-rentable). See also [Not in Search](/host/not-in-search). ## Generic Red Machine Error diff --git a/host/maintenance-windows.mdx b/host/maintenance-windows.mdx index d667373c..a2073b2d 100644 --- a/host/maintenance-windows.mdx +++ b/host/maintenance-windows.mdx @@ -54,6 +54,10 @@ See [vastai list machine](/host/cli/list-machine), [vastai list machines](/host/ ## Schedule An Unplanned Window + + ![Machine maintenance scheduling form](/images/host-maintenance-window.webp) + + If you must take the machine down unexpectedly, schedule a maintenance window so clients are notified and can save their work. The start date is Unix epoch seconds in UTC, and the duration is in hours. ```bash @@ -68,6 +72,10 @@ vastai schedule maint 8207 --sdate 1782950400 --duration 2 --maintenance_categor Maintenance categories are `power`, `internet`, `disk`, `gpu`, `software`, or `other`. See [vastai schedule maint](/host/cli/schedule-maint). + + ![Machine maintenance reason options](/images/host-maintenance-reason.webp) + + ## Check Or Cancel Maintenance diff --git a/host/market-metrics.mdx b/host/market-metrics.mdx index 2e0acfc8..c3b128d5 100644 --- a/host/market-metrics.mdx +++ b/host/market-metrics.mdx @@ -32,6 +32,10 @@ If you are evaluating hardware before registering a machine, public third-party - **GPU Locations**: geographic distribution. - If the page is blocked by the registered-machine requirement, use the Vast hosting calculator or public market dashboards for preliminary planning until the host account has a registered machine. + + ![Host Market GPU Overview](/images/host-market-gpu-overview.webp) + + ## GPU Overview Income Estimate In [Host Market](https://cloud.vast.ai/host/market/), open **GPU Overview** to compare demand and pricing by GPU model. diff --git a/host/notifications.mdx b/host/notifications.mdx new file mode 100644 index 00000000..3bcc8223 --- /dev/null +++ b/host/notifications.mdx @@ -0,0 +1,70 @@ +--- +title: "Host Notifications" +sidebarTitle: "Notifications and Webhooks" +description: "Choose which Vast.ai host machine, verification, and maintenance events reach you by email or through webhooks." +"canonical": "/host/notifications" +personas: + - pro-operator + - headless-operator + - business-owner + - hobbyist +--- + +import NotificationChannels from '/snippets/notifications/channels.mdx'; + +
All host personas
+ +If you provide machines on Vast.ai, host notifications keep you informed about machine health, verification, renter reports, and maintenance. You can receive them by email or through webhooks that feed your own monitoring and incident systems. + +The notification system is shared across the web console and the API. The console gives you a settings page for choosing events and destinations. The API gives developers access to the same notification types, preferences, and webhook tools. + + +This page covers host notifications. For renter events such as low balance and instance lifecycle, see [Notifications](/guides/reference/notifications). + + +## Where to Find Notification Settings + +Open [Account Settings](https://cloud.vast.ai/account/) and go to **Notification Settings**. Host events appear only when you provide machines on Vast.ai. + +The **Host** group covers: + +| Group | What it covers | +| --- | --- | +| **Host** | Hosted machine health, verification failures, renter reports, maintenance, host disk warnings, and host-side contract events | + + + +## Update Host Notifications in the Console + +1. Open **Account Settings**. +2. Go to **Notification Settings**. +3. Review the **Host** section. +4. Turn off email for any optional event you do not want in your inbox. +5. Select host events that should be sent to a webhook, then create or edit the webhook destination. +6. Click **Save**. + +## Route Host Alerts to a Webhook + +Webhooks are useful for turning host alerts into actions, such as paging an operator when a hosted machine fails verification or goes offline, or routing host machine alerts into your monitoring stack. + +A common host action mapping points each host event at its machine in the console, for example: + +| Notification type | Console destination | +| --- | --- | +| `machine_offline` | `https://cloud.vast.ai/host/machines/` | +| `machine_error` | `https://cloud.vast.ai/host/machines/` | +| `maintenance_scheduled` | `https://cloud.vast.ai/host/machines/` | + +See [Notification Webhooks](/guides/reference/notification-webhooks) for setup, payloads, signing, retries, and API examples. + +## Notification Type Keys + +Notification types are identified by a `key` with a context prefix. Host events use the `host:` prefix, such as `host:machine_offline`. + +Use the full `key` when subscribing webhooks or updating preferences. Because a similar event can exist for both renters and hosts, the full key avoids ambiguity. For the complete list of types, display names, and default channel settings, call [`GET /notification-types/`](/api-reference/notifications/list-notification-types). + +## Good Defaults + +If you are hosting machines, keep machine error, verification, report, maintenance, and low-disk alerts enabled. + +Review the settings periodically, especially after adding hosted machines or connecting a new automation workflow. diff --git a/host/pricing-your-listing.mdx b/host/pricing-your-listing.mdx index 866b02c9..13365bf4 100644 --- a/host/pricing-your-listing.mdx +++ b/host/pricing-your-listing.mdx @@ -38,6 +38,10 @@ Confirm: DPH means dollars per hour, usually GPU compute price. + + ![Machine listing and pricing controls](/images/host-listing-pricing-controls.webp) + + ## Starting Workflow 1. Compare same GPU model, GPU count, verification state, region, and machine quality. diff --git a/host/quickstart.mdx b/host/quickstart.mdx index c449717a..812c4c3e 100644 --- a/host/quickstart.mdx +++ b/host/quickstart.mdx @@ -14,9 +14,10 @@ Follow this ordered path for a first-time Vast host setup. Each step links to th ## Setup Path 1. **Decide whether Vast fits your hardware and expectations.** Read [Is Vast for Me?](/host/persona-decision-guide) and check your hardware against [Supported Hardware](/host/supported-hardware). -2. **Create a dedicated host account and accept the hosting agreement.** Start from the official [Vast host setup page](https://cloud.vast.ai/host/setup/), then see [Account & Hosting Agreement](/host/account-hosting-agreement) if the account flow gets stuck. +2. **Create a dedicated host account and accept the hosting agreement.** Start from the official [Vast host setup page](https://cloud.vast.ai/host/setup/), then see [Host Account and Agreement](/host/account-hosting-agreement) if the account flow gets stuck. Before using keys or automation, review [Host Account Security](/host/account-security-for-hosts). 3. **Prepare the machine.** Set up Ubuntu, drivers, and BIOS with [Hardware Prep](/host/hardware-prep), disks with [Storage Setup](/host/storage-setup), and port forwarding with [Network & Ports](/host/network-ports). 4. **Install the Vast host software with the Host Installer Wizard (TUI).** Copy the current command from the official [Vast host setup page](https://cloud.vast.ai/host/setup/), then use [Installing Host Software](/host/installing-host-software#host-installer-wizard) to understand the install flow. Running over SSH only? Use the [Headless Install Path](/host/headless-install). 5. **List the machine.** Set your price and terms with [Pricing Your Listing](/host/pricing-your-listing). 6. **Run the self-test.** Follow [How to Self-Test](/host/how-to-self-test), and decode any failures with the [Self-Test Reference](/host/self-test-reference). 7. **Understand verification and first-rental expectations.** Read [Understanding Verification](/host/understanding-verification) and [First 24 Hours After Install](/host/first-24-hours). +8. **Start earning.** Understand how rentals turn into income with the [Earnings Model](/host/earning), and set up payouts with [Host Payouts](/host/payment). diff --git a/host/self-test-reference.mdx b/host/self-test-reference.mdx index ff6e01f3..c4d5a002 100644 --- a/host/self-test-reference.mdx +++ b/host/self-test-reference.mdx @@ -1,7 +1,7 @@ --- -title: "Self-Test Reference" -sidebarTitle: "Self-Test Reference" -description: "Lookup reference for self-test failures, meanings, and next actions." +title: "Verification / Self-test Reference" +sidebarTitle: "Self-test Reference" +description: "Generated reference for host self-test checks, thresholds, failure codes, and guidance." "canonical": "/host/self-test-reference" personas: - pro-operator @@ -10,78 +10,222 @@ personas:
Pro OperatorHeadless / DC
-Use this reference to look up self-test failures, understand what they usually mean, and choose the next diagnostic step. Each failure has its own anchor so CLI errors and support replies can link directly to it. +{/* + This page is generated by scripts/generate_self_test_reference.py. + Do not edit this file by hand; update the Vast CLI/self-test source metadata, then regenerate. + Source: vast-ai/vast-cli master@d4316fb dirty=no. + Source: vast-ai/self-test main@6f93fc4 dirty=no. +*/} -## Quick Error Lookup +The host self-test is the quickest way to check whether a listed machine can pass Vast.ai's minimum verification gate and run the runtime workload used by the tester. -Use the exact message printed by the CLI. Fix the host-side cause, then rerun self-test. +When you run `vastai self-test machine `, the CLI selects a rentable offer for that machine, checks minimum requirements, rents one temporary diagnostic instance, starts the self-test image, polls the runtime progress endpoint, reports the result, and destroys the temporary instance. -| Message or stage | Usually means | Host action | + +Passing this self-test makes a machine eligible for verification, but it does not guarantee that the machine will be verified immediately. Verification also depends on ongoing health, reliability, supply and demand, and platform policy. + + + +`--ignore-requirements` is for advanced runtime diagnostics only. A pass with requirement checks ignored does not qualify the machine for verification. + + +## How To Read The Result + +Self-test output has two distinct parts: preflight qualification checks and the runtime workload. + +| Result | What it means | What to do next | | --- | --- | --- | -| `Machine not found or not rentable` | The machine is not testable from the current account or has no rentable offer. | Check listing state, active offers, account/API key, reliability, red errors, and missing machine details. | -| `Reliability <= 0.90` | The machine has not met the reliability gate. | Keep the host online and stable; use diagnostic mode only for troubleshooting, not verification proof. | -| `Authorization Error`, `HTTP 401`, or missing permission | The API key cannot inspect the machine or offer. | Use a host-enabled account/API key with the needed machine and offer permissions. | -| `CUDA compatibility` failure | Driver/runtime support is too old for the self-test image. | Update or repair the NVIDIA driver stack, then confirm Docker GPU access. | -| Direct ports below requirement | The machine does not expose enough direct ports for the listed GPUs. | Configure and forward at least 3 TCP/UDP ports per listed GPU for self-test; use more headroom for production hosting. | -| PCIe bandwidth failure or `bad bandwidthtest2` | GPU transfer or PCIe health is below the gate. | Check PCIe lanes, risers, slots, power, thermals, Xid/AER logs, and GPU seating. | -| Upload/download bandwidth failure | Network throughput is below the machine requirement. | Check ISP/datacenter bandwidth, routing, host load, and repeated speed results. | -| VRAM, RAM, or physical CPU core failure | Hardware resources do not meet the listed GPU requirement. | Confirm visible GPUs, host RAM, and at least one physical CPU core per visible GPU. | -| `progress_endpoint_unreachable`, `progress_empty_timeout`, `progress_endpoint_lost`, or `No response for 120s` | The temporary test instance did not provide progress. | Check public TCP reachability, container startup, Docker/NVIDIA runtime, daemon logs, and the support bundle. | -| UDP echo failure | TCP may work, but the public UDP path failed. | Forward UDP for the same direct port range and test from outside the LAN. | -| System requirements failure | The container could not confirm core host resources. | Check RAM, CPU cores, GPU visibility, and container logs in the bundle. | -| ResNet, ECC, or `gpu-burn` failure | GPU compute, memory, ECC, thermal, or stability issue. | Check `nvidia-smi`, Xid/NVRM logs, thermals, power, and rerun after fixing hardware/driver issues. | -| `failed to inject CDI devices` or `nvidia-container-cli` | Docker could not inject GPUs into the container. | Confirm `nvidia-smi` and a normal Docker GPU container both work. | -| `nccl_failed` | Multi-GPU communication failed. | Check CUDA/NCCL compatibility, PCIe/NVLink topology, P2P, Xid logs, and Fabric Manager on NVSwitch systems. | +| Normal pass | Minimum requirements passed and the runtime workload passed. The machine is eligible for verification, subject to the normal platform verification process. | Keep the host stable and listed. Verification is still automated and not guaranteed immediately. | +| Normal preflight failure | The CLI found one or more requirement failures before renting a temporary instance. | Fix the measured values shown in the CLI, then rerun without `--ignore-requirements`. | +| Runtime failure | The CLI rented a temporary instance, started the self-test image, and a runtime stage failed or timed out. | Use the failure code, last runtime stage, and diagnostic bundle to identify the failing subsystem. | +| Pass with `--ignore-requirements` | The runtime workload passed, but minimum requirement checks were skipped. This does not qualify the machine for verification. | Treat this as runtime validation only. Rerun without `--ignore-requirements` to see qualification status. | -
-## vericode=8 And Port Networking Issues + +If you use `--ignore-requirements`, still review any requirement diagnostics from a normal run. A runtime pass proves the container workload can run; it does not prove the machine meets the verification gate. + -[Glossary: vericode](/host/glossary#vericode) +## Preflight Checks -`vericode=8` means the machine has a host-facing `error_msg` in the platform state. If the visible message is `Port Networking Issues`, `Port issue`, or similar wording, treat it as a direct-port or connectivity problem. Other `vericode=8` cases can point to Docker/NVIDIA runtime, CDI, PCIe, daemon, or other machine errors, so use the exact console message when choosing the next fix. +These checks run before the CLI rents the temporary self-test instance. Failed required checks stop the normal flow before billing starts. -For port-networking cases, common causes include closed external ports, forwarding to the wrong LAN IP, CGNAT or double NAT without a real public forwarding path, no inbound public IP, firewall rules, TCP/UDP mismatch, asymmetric protocol handling, or a stale port range. Test the public IP:port if available, verify router and host firewall rules, then rerun the self-test after fixing the network path. See [Network & Ports](/host/network-ports) for the full connectivity checklist and [Machine Error Reference](/host/machine-errors) for non-port `vericode=8` errors. +| Check | Gate | Purpose | Host guidance | +| --- | --- | --- | --- | +| `cuda.version`
CUDA version | Required: CUDA version >= 11.8 | The self-test image needs a host driver stack compatible with CUDA 11.8 or newer. | Update the NVIDIA driver/CUDA stack, then confirm the machine is listed and healthy in the Console. | +| `reliability`
Reliability | Required: Reliability > 0.90 | Self-test uses reliability as a guardrail before launching a temporary instance. | Let the host stabilize and resolve recent failures before retrying the self-test. | +| `network.direct_ports`
Direct port count | Required: Direct ports >= 3 * listed GPUs | The tester needs at least 3 directly mapped ports per listed GPU for remote progress, SSH checks, and runtime port allocation. | Open at least 3 direct ports for the listed GPU count, check firewall/NAT forwarding, and make TCP/UDP forwarding symmetric where required. | +| `pcie.bandwidth`
PCIe bandwidth | Required: PCIe bandwidth > 2.85 GB/s | Low PCIe bandwidth can make GPU stress and transfer checks fail or time out. | Check BIOS PCIe generation/lane settings and confirm GPUs are seated in full-speed slots. | +| `network.download`
Download speed | Required: Download >= min(500, max(100, 500 * total_vram_gib / 192)) Mb/s | The bandwidth floor scales with total VRAM so large GPU hosts can complete data movement tests. | Improve host download bandwidth or reduce contention, then rerun the Vast host verification. | +| `network.upload`
Upload speed | Required: Upload >= min(500, max(100, 500 * total_vram_gib / 192)) Mb/s | The tester needs enough upload bandwidth to report progress and complete network checks. | Improve host upload bandwidth or reduce contention, then rerun the Vast host verification. | +| `gpu.ram`
GPU RAM | Required: Per-GPU VRAM > 7 GiB | The verification workload requires more than 7 GB of VRAM per GPU. | Use a GPU with more VRAM for this self-test. | +| `system.ram`
System RAM | Required: System RAM >= min(0.95 * total GPU VRAM, 2,000,000 MiB) | System RAM must be close to total VRAM so CPU-side staging does not starve the tests. For very large GPU hosts, this requirement is capped at about 2 TB. | Add system RAM or reduce the listed GPU set so system RAM is at least 95% of total VRAM, up to the 2 TB cap. | +| `cpu.cores`
CPU cores | Required: Physical CPU cores >= listed GPUs | The tester expects at least one physical CPU core per listed GPU. Hyperthreads/logical CPUs do not count as physical cores. | Add physical CPU cores or reduce the listed GPU count for this offer. | +| `network.direct_ports.recommended_max`
Direct port count advisory | Advisory: direct ports <= 64 * listed GPUs | Vast instances can use at most 64 open ports each. Mapping more than 64 ports per listed GPU is usually wasted effort. | This is advisory only, not a self-test gate. Keep enough direct ports for self-test and normal workloads, but avoid mapping very large unused ranges. | -
-## No response for 120s + +For B300 and other very-high-VRAM hosts, the system RAM gate stops scaling at 2,000,000 MiB (about 2 TB). + + +### Direct Ports And Port Mapping + +The self-test needs direct public connectivity to the temporary instance. The progress service runs inside the self-test container on `5000/tcp`, but the CLI connects to the mapped external public IP and external port reported by the instance. + +- Minimum gate: at least 3 direct ports per listed GPU. +- Useful cap: each instance can use up to 64 ports. Mapping more than 64 ports per listed GPU is usually unnecessary and is not a self-test requirement. +- Port forwarding should target the host's LAN address, not its public address. +- Keep TCP and UDP forwarding symmetric where your network setup requires both protocols. +- If the CLI reports a tested external IP:port, troubleshoot that external mapping first. +- If the host and CLI are on the same LAN, a local failure to reach the public IP can be NAT hairpinning. Retest from an outside network before assuming the port is closed globally. + + +The CLI can report the external progress port it tested when that mapping is available. A full list of exactly which direct ports failed still requires backend or daemon-side exposure. + + +### Bandwidth Formula + +Upload and download thresholds scale with total machine VRAM: + +```text +required_mbps = min(500, max(100, 500 * total_vram_gib / 192)) +``` + +| Total machine VRAM | Required upload and download | +| --- | --- | +| 8 GiB total VRAM | 100 Mb/s | +| 48 GiB total VRAM | 125 Mb/s | +| 80 GiB total VRAM | 208.33 Mb/s | +| 96 GiB total VRAM | 250 Mb/s | +| 160 GiB total VRAM | 416.67 Mb/s | +| 192 GiB total VRAM or more | 500 Mb/s | + +## Self-test Image Selection + +The CLI selects from the self-test image family unless `--test-image` or `VAST_SELF_TEST_IMAGE` overrides the image for controlled testing. + +| CLI image | Torch | Targets | Platforms | +| --- | --- | --- | --- | +| `vastai/test:self-test-v2-cuda-11.8` | 2.7.1 | Pre-sm_70 (Maxwell, Pascal); older R450+ drivers | linux/amd64 | +| `vastai/test:self-test-v2-cuda-12.8` | 2.10.0 | sm_70+ (Volta and newer); current default | linux/amd64, linux/arm64 | +| `vastai/test:self-test-v2-cuda-13.0` | 2.11.0 | sm_75+ (Turing and newer); cu130 wheels never shipped sm_70 | linux/amd64, linux/arm64 | +| `vastai/test:self-test-v2-cuda-13.3` | 2.12.0 | sm_75+ (Turing and newer); CUDA 13.3 runtime with latest available CUDA-13 PyTorch wheels (`cu132`) | linux/amd64, linux/arm64 | -A `no response` or progress timeout means the CLI could not get usable progress from the temporary self-test instance after it was created. Newer CLI output may show a more specific code such as `progress_endpoint_unreachable`, `progress_empty_timeout`, or `progress_endpoint_lost`. Start with connectivity and startup checks: +Selection rules: -- Inspect the tested external IP:port if the CLI printed one. -- Confirm router or firewall forwarding to the host LAN IP. -- Retry from an outside network to rule out NAT hairpinning; see [testing from outside your LAN](/host/network-ports#test-ports-outside-lan). -- Inspect the diagnostic bundle for instance status, container logs, and daemon logs. +- Pre-Volta GPUs (`compute_cap < 700`) use the CUDA 11.8 image. +- Volta GPUs (`compute_cap < 750`) are capped at CUDA 12.8 because newer PyTorch CUDA 13 wheels do not include sm_70 support. +- Other hosts use the newest supported self-test image that is less than or equal to `cuda_max_good`. -Other likely causes include container startup failure, Docker/NVIDIA runtime stalls, host daemon stalls, GPU or system hangs, or upload instability. +## Runtime Stages + +After preflight passes, the CLI starts the self-test image and polls the runtime progress service on container port `5000/tcp`. + +| Stage | Pass condition / threshold | Purpose | Failure guidance | +| --- | --- | --- | --- | +| `image_started`
Self-test image started | The runtime container starts and writes the first progress event. | Confirm the self-test runtime started and can report progress. | If this is the last event, inspect container startup logs and Python import errors. | +| `system_requirements`
System requirements | Each GPU has at least 98% free VRAM; system RAM is at least 95% of total GPU VRAM capped at 2,000,000 MiB; there is at least 1 visible physical CPU core per visible GPU. Hyperthreads/logical CPUs do not count as physical cores. | Verify GPU visibility, available VRAM, host RAM, and CPU core capacity. | Check CUDA visibility, host memory, CPU allocation, and competing GPU workloads. | +| `resnet`
ResNet GPU execution | A CUDA ResNet18 workload completes on the visible GPU set at any tested batch size. | Run a CUDA ResNet18 workload across visible GPUs. | Check PyTorch CUDA compatibility, GPU memory pressure, and driver health. | +| `ecc`
ECC memory allocation | The test allocates 95% of total memory on each visible GPU. | Allocate most GPU memory on each visible GPU to surface memory/ECC failures. | Check GPU ECC/Xid errors, available VRAM, and device health. | +| `nccl`
NCCL distributed communication | At least 1 GPU is visible and all NCCL ranks initialize and synchronize on one machine. | Initialize NCCL workers across all visible GPUs and synchronize them. | Check NCCL support, peer communication, CUDA driver/runtime compatibility, and process limits. | +| `stress_gpu_burn`
CPU stress and GPU burn | stress-ng and gpu-burn run together for 60 seconds and both exit with code 0. | Run stress-ng and gpu-burn together to exercise sustained host and GPU load. | Check thermals, power limits, gpu-burn output, stress-ng output, and system stability. | +| `final_summary`
Final summary | The runtime reports the overall pass/fail result and exit code. | Report the overall self-test result. | Review the failed stage event and raw output tails for the first actionable failure. | + +## Offer Selection And Preflight Issues + +The CLI reports stable preflight failure codes and, when possible, a likely root state for machines that are not currently rentable.
-## Machine not found or not rentable +### Not Found Or Not Rentable -This can mean the machine is already rented in a way that hides the expected offer, has zero active on-demand offers, is offline or unlisted, is not visible to the account running the test, is deverified or below a threshold, or has offer-side error metadata. +The old `not found or not rentable` wording hid several different states. The newer CLI tries to disambiguate the state before giving guidance. -Start with: +Typical root causes: -1. Open the Machines page for the host account. -2. Confirm the machine is listed, online, not currently rented, and has active offers. -3. Check reliability, verification state, red machine errors, and missing details such as ports, RAM, upload speed, and download speed. -4. Confirm the CLI is authenticated to the same host-enabled account. -5. Search directly for the machine ID; see [Why Isn't My Machine in Search?](/host/not-in-search#check-machine-listed). -6. If the console state looks wrong after refresh and rerun, collect logs and escalate to support. +- The machine is currently rented. +- The machine is visible but has zero active on-demand offers. +- The machine is offline, unlisted, or not visible to the API account. +- The machine is deverified, below the reliability threshold, or has offer-side error metadata. +- The API key can authenticate but does not have permission to inspect the required host or offer state. - -## `nccl_failed` +Start with the machine page in the Console. Check whether the host is online, listed, already rented, below the reliability gate, missing an active on-demand offer, or being viewed from an account that cannot see the host state. + +| Code or root state | Meaning | Guidance | +| --- | --- | --- | +| `no_offer` | No on-demand offer found for the machine. | Confirm the host is online, listed, and has a visible on-demand offer in the Console. | +| `no_rentable_offer` | Offers were visible, but none were currently rentable. | Wait for rentals/state refreshes or inspect host offer state. | +| `api_permission_failed` | The API key could not inspect the required machine/offer state. | Use an API key/account with host machine and offer visibility. | +| `preflight_requirements_failed` | One or more minimum requirement checks failed before renting. | Resolve the failed checks or rerun with --ignore-requirements for runtime diagnostics only. | +| `currently_rented` | Visible offers exist and one or more are already rented. | Review the machine page, listing state, active rentals, and offer visibility in the Console. | +| `deverified_or_below_threshold` | Visible offers exist but host reliability, verification state, vericode, or error metadata points to a host quality gate. | Review the machine page, listing state, active rentals, and offer visibility in the Console. | +| `zero_active_offers` | The machine is visible, but no active on-demand offers are listed for it. | Review the machine page, listing state, active rentals, and offer visibility in the Console. | +| `offline_or_not_listed` | The machine is not visible to the account or appears offline/not listed. | Review the machine page, listing state, active rentals, and offer visibility in the Console. | +| `unknown_no_rentable_offer` | Visible offers exist, but the payload does not expose a specific non-rentable reason. | Review the machine page, listing state, active rentals, and offer visibility in the Console. | + + +## vericode=8 And Port Networking Issues -`nccl_failed` means the temporary self-test instance could not initialize and synchronize NCCL workers across the visible GPUs. Treat this as a multi-GPU communication failure, not as proof of one specific root cause. +[Glossary: vericode](/host/glossary#vericode) + +`vericode=8` means the machine has a host-facing `error_msg` in platform state. If the visible message is `Port Networking Issues`, `Port issue`, or similar wording, treat it as a direct-port or connectivity problem. Other `vericode=8` cases can point to Docker/NVIDIA runtime, CDI, PCIe, daemon, or other machine errors, so use the exact Console message when choosing the next fix. + +For port-networking cases, check closed external ports, forwarding to the wrong LAN IP, CGNAT or double NAT, missing inbound public IP, host firewall rules, TCP/UDP mismatch, asymmetric protocol handling, and stale port ranges. See [Network & Ports](/host/network-ports) and [Machine Error Reference](/host/machine-errors). + +## Runtime Failure Codes + +Runtime failure codes are stable identifiers intended for CLI output, support workflows, and host-facing guidance. -Start with: + +### No Response Or Progress Timeout + +A `no response` or progress timeout means the CLI could not get usable progress from the temporary self-test instance after it was created. This is usually a connectivity or startup problem, not a generic verification decision. + +Common causes: + +- Router or firewall forwards the external port to the wrong LAN IP. +- The external TCP port is closed, blocked, or not hairpin-accessible from the CLI's network. +- The self-test container never started, crashed, or did not bind the progress service. +- Docker, NVIDIA runtime, or the host daemon stalled during startup. +- The GPU or system hung under load before progress could be reported. +- Upload/network instability prevented progress responses from reaching the CLI. + +First checks: + +- Look at the failure code and the tested external IP:port in CLI output when present. +- Confirm the router/firewall forwards that external port to the host machine. +- Inspect the diagnostic bundle for `instance/container.log`, `instance/daemon.log`, and `instance/show-instance.json` when the instance existed. +- If you ran the CLI from the same LAN as the host, retry from a different network to rule out NAT loopback/hairpinning. + +| Code | Area | Meaning | Remediation | Suggested steps | +| --- | --- | --- | --- | --- | +| `instance_create_failed` | Launch, network, or cleanup | Failed to create the runtime test instance. | Check the offer, docker image, and instance creation response. | Retry with --debugging enabled.
Inspect the create-instance API error. | +| `instance_create_missing_contract` | Launch, network, or cleanup | Instance creation did not return a new contract id. | Treat the create response as malformed or incomplete. | Inspect the raw create-instance response.
Retry after confirming the offer is still rentable. | +| `instance_status_error` | Launch, network, or cleanup | The instance reported an error while starting. | Inspect the instance status message and host/container logs. | Run show instance for the contract.
Check docker logs on the host if available. | +| `instance_status_poll_failed` | Launch, network, or cleanup | Failed to poll instance status. | Confirm API connectivity and retry the status check. | Retry the CLI command.
Check network/API errors from the status request. | +| `instance_start_timeout` | Launch, network, or cleanup | The instance did not reach running state before timeout. | Check host capacity, docker startup, and network configuration. | Inspect instance status_msg.
Try the test again after confirming the host is healthy. | +| `instance_offline_before_test` | Launch, network, or cleanup | The instance went offline before the runtime test could run. | Investigate host availability and instance lifecycle events. | Check machine status.
Review host daemon and container logs. | +| `missing_public_ip` | Launch, network, or cleanup | The running instance did not expose a public IP address. | Confirm the instance network configuration and public IP assignment. | Inspect show instance output.
Retry on a machine with public networking available. | +| `progress_port_not_mapped` | Launch, network, or cleanup | The runtime progress port was not mapped. | Confirm port 5000/tcp is exposed and direct ports are available. | Check the available mapped ports in the diagnostic output.
Verify the machine has enough direct ports. | +| `progress_endpoint_unreachable` | Launch, network, or cleanup | The runtime progress endpoint was never reachable. | Check TCP firewall/NAT forwarding, direct port mapping, container startup, and NAT hairpinning. | Confirm the mapped public TCP port forwards to the host LAN IP.
Inspect docker logs to confirm the progress server bound port 5000/tcp.
If testing from the same LAN as the host, retry from an outside network to rule out NAT loopback/hairpinning. | +| `progress_endpoint_lost` | Launch, network, or cleanup | The runtime progress endpoint became unreachable after connecting. | Look for container crashes, OOM, GPU errors, or host instability. | Inspect docker logs for a crash or missing progress server.
Check dmesg for Xid, GPU reset, OOM, or host stall messages.
Check for network loss between the CLI and host public endpoint. | +| `progress_empty_timeout` | Launch, network, or cleanup | The progress endpoint returned no new output before timeout. | Check whether the runtime script stalled or stopped writing progress. | Inspect runtime logs.
Retry with debugging enabled. | +| `runtime_test_timeout` | Launch, network, or cleanup | The runtime test did not complete before timeout. | Investigate long-running or stalled test stages. | Check the last reported progress stage.
Run individual tests to isolate the stall. | +| `legacy_progress_error` | Runtime test | The legacy runtime progress stream reported an unclassified error. | Use the raw error text and active stage to decide the next action. | Inspect the original ERROR line.
Retry with debugging enabled if the cause is unclear. | +| `docker_pull_failed` | Image or container startup | The test image could not be pulled. | Check image name, tag availability, registry access, and credentials. | Verify the docker image tag exists.
Check for registry unauthorized or denied errors. | +| `daemon_startup_failed` | Image or container startup | The container or daemon failed during startup. | Inspect docker daemon, OCI runtime, and container startup logs. | Check docker logs.
Verify NVIDIA container runtime and host daemon health. | +| `nvml_failed` | Runtime test | NVML or nvidia-smi failed during system checks. | Check NVIDIA driver, NVML, and GPU visibility on the host. | Run nvidia-smi on the host.
Check driver/library mismatch or GPU reset errors. | +| `resnet_failed` | Runtime test | The ResNet runtime test failed. | Check PyTorch/CUDA health, available VRAM, and GPU compute stability. | Look for CUDA OOM, cuDNN, or torch runtime errors.
Run a smaller isolated torch workload. | +| `ecc_failed` | Runtime test | The ECC runtime test failed. | Check GPU ECC counters and hardware health. | Inspect ECC error counters.
Review dmesg and nvidia-smi health output. | +| `nccl_failed` | Runtime test | The NCCL distributed runtime test failed. | Check multi-GPU connectivity, NCCL transport, and network fabric. | Inspect NCCL error output.
Verify peer-to-peer and multi-GPU communication. | +| `stress_gpu_burn_failed` | Runtime test | The stress-ng or gpu-burn runtime test failed. | Check thermals, power stability, GPU Xid errors, and host stress logs. | Inspect dmesg for Xid errors.
Review gpu-burn and stress-ng output. | +| `interrupted` | Launch, network, or cleanup | The runtime test was interrupted. | Ensure cleanup completed or destroy the test instance manually. | Check whether the test instance still exists.
Destroy leaked instances if needed. | +| `cleanup_failed` | Launch, network, or cleanup | Runtime test cleanup failed. | Destroy the temporary test instance manually to avoid continued billing. | Run destroy instance for the temporary contract.
Retry cleanup after checking API connectivity. | + +
+### `nccl_failed` Deep Dive -1. Inspect the self-test support bundle for the NCCL error output. -2. Confirm every GPU is visible with `nvidia-smi` and Docker GPU access. -3. Check driver/CUDA/PyTorch/NCCL compatibility. -4. Check GPU peer-to-peer communication, PCIe/NVLink topology, Xid errors, and any GPU reset or bus errors. -5. On HGX, DGX, or other NVSwitch systems, check Fabric Manager/NVLSM on the host. The self-test container can show NCCL symptoms, but it cannot confirm whether host services such as `nvidia-fabricmanager` are running or healthy. +`nccl_failed` means the temporary self-test instance could not initialize and synchronize NCCL workers across the visible GPUs. It is a multi-GPU communication failure, not proof of one specific root cause. -Useful host-side checks for NVSwitch systems include: +Start with the support bundle, confirm every GPU is visible to `nvidia-smi` and Docker, then check driver/CUDA/PyTorch/NCCL compatibility, peer-to-peer communication, PCIe/NVLink topology, Xid errors, GPU resets, and bus errors. + +On HGX, DGX, or other NVSwitch systems, also check Fabric Manager or NVLSM on the host. The self-test container can show NCCL symptoms, but it cannot confirm whether host services such as `nvidia-fabricmanager` are healthy. ```bash systemctl status nvidia-fabricmanager @@ -90,17 +234,20 @@ nvidia-smi -q | grep -i -A 2 Fabric nvidia-smi topo -m ``` -Fabric state should normally show completed/successful registration on systems where NVIDIA exposes fabric state through NVML. If Fabric Manager is not applicable to the hardware, focus on driver/runtime compatibility, PCIe/NVLink topology, and the raw NCCL error. - - -## Which warnings can be ignored? +## Diagnostic Bundles -Avoid ignoring warnings by default. One clear self-test advisory is that mapping more than the minimum number of direct ports per GPU is not itself a self-test gate. For production hosting, about 100 forwarded TCP/UDP ports per listed GPU is still practical headroom because individual instances can use many ports and old mappings may overlap with new starts. Red machine errors, self-test failures, NVML/Xid errors, port networking issues, and not-visible/rentable states should be investigated. +When a self-test fails, the CLI builds a redacted diagnostic tarball unless bundle creation is disabled. -## Reliability below the preflight gate +- Default output directory: `/tmp`. +- Disable automatic bundles with `--no-support-bundle` or `VAST_SELF_TEST_SUPPORT_BUNDLE=0`. +- Choose another directory with `--support-bundle-dir `. +- Create a manual CLI-visible bundle with `vastai dump-logs `. +- Include local host OS/kaalia artifacts only when running on the actual host with `vastai dump-logs --include-local-host-artifacts`. -Reliability greater than 0.9 is a self-test and verification gate; if the machine is at or below it, the self-test can fail before renting the temporary instance. See [Reliability & Uptime](/host/reliability-uptime#reliability-threshold). +Default self-test bundles include `self-test-output.log`, `self-test-result.json`, `manifest.json`, and `collection-errors.json`. Runtime failures with a created instance can also include `instance/show-instance.json`, `instance/container.log`, and `instance/daemon.log` from the Vast instance logs API. -## Errors not specific to the self-test + +When the CLI is run from a laptop or other third-party machine, it cannot collect host-local files such as `/var/lib/vastai_kaalia/kaalia.log*`, `dmesg`, `journalctl`, `/etc/docker/daemon.json`, or `/proc/mounts` from the Vast host. Those artifacts require running the helper on the actual host or adding a future daemon/backend log-collection feature. + -For `bad bandwidthtest2`, CDI device injection failures, NVML mismatches, and other machine-health errors, see [Machine Error Reference](/host/machine-errors) and [Host Diagnostics](/host/common-errors-diagnostics). +Text artifacts are capped at 262,144 bytes and log artifacts are capped at 262,144 bytes. Obvious API keys, tokens, passwords, and related secrets are redacted, but hosts should still review the tarball before sharing it with support. diff --git a/images/console-notification-webhook.png b/images/console-notification-webhook.png new file mode 100644 index 00000000..acede02e Binary files /dev/null and b/images/console-notification-webhook.png differ diff --git a/images/console-notifications-settings.png b/images/console-notifications-settings.png new file mode 100644 index 00000000..c878e22e Binary files /dev/null and b/images/console-notifications-settings.png differ diff --git a/images/host-console-host-navigation.webp b/images/host-console-host-navigation.webp new file mode 100644 index 00000000..ff3652b3 Binary files /dev/null and b/images/host-console-host-navigation.webp differ diff --git a/images/host-listing-pricing-controls.webp b/images/host-listing-pricing-controls.webp new file mode 100644 index 00000000..f528bdde Binary files /dev/null and b/images/host-listing-pricing-controls.webp differ diff --git a/images/host-machine-health-status.webp b/images/host-machine-health-status.webp new file mode 100644 index 00000000..c133bbce Binary files /dev/null and b/images/host-machine-health-status.webp differ diff --git a/images/host-machine-problem-reports.webp b/images/host-machine-problem-reports.webp new file mode 100644 index 00000000..4c83bfec Binary files /dev/null and b/images/host-machine-problem-reports.webp differ diff --git a/images/host-machines-list.webp b/images/host-machines-list.webp new file mode 100644 index 00000000..53f05627 Binary files /dev/null and b/images/host-machines-list.webp differ diff --git a/images/host-maintenance-reason.webp b/images/host-maintenance-reason.webp new file mode 100644 index 00000000..08db07fb Binary files /dev/null and b/images/host-maintenance-reason.webp differ diff --git a/images/host-maintenance-window.webp b/images/host-maintenance-window.webp new file mode 100644 index 00000000..5499fc0f Binary files /dev/null and b/images/host-maintenance-window.webp differ diff --git a/images/host-market-gpu-overview.webp b/images/host-market-gpu-overview.webp new file mode 100644 index 00000000..e6cbac72 Binary files /dev/null and b/images/host-market-gpu-overview.webp differ diff --git a/images/host-self-test-machine-menu.webp b/images/host-self-test-machine-menu.webp new file mode 100644 index 00000000..0df1dfbb Binary files /dev/null and b/images/host-self-test-machine-menu.webp differ diff --git a/images/host-self-test-status.webp b/images/host-self-test-status.webp new file mode 100644 index 00000000..5d4ef266 Binary files /dev/null and b/images/host-self-test-status.webp differ diff --git a/images/host-setup-install-manager.webp b/images/host-setup-install-manager.webp new file mode 100644 index 00000000..c6c39757 Binary files /dev/null and b/images/host-setup-install-manager.webp differ diff --git a/package.json b/package.json index 3973d954..4ecb3d12 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "scripts": { "build-openapi": "python3 api-reference/openapi/build.py", "check-openapi": "mint openapi-check api-reference/openapi.yaml", + "check-persona-chips": "python3 scripts/check_persona_chips.py", + "generate-self-test-reference": "python3 scripts/generate_self_test_reference.py", + "test-review-context": "node --test scripts/review-context.test.mjs", "dev": "mint dev" }, "dependencies": { diff --git a/review-questions.mdx b/review-questions.mdx new file mode 100644 index 00000000..b7af3b02 --- /dev/null +++ b/review-questions.mdx @@ -0,0 +1,95 @@ +--- +title: "Reviewer Inputs and Jira Gates" +sidebarTitle: "Review Questions" +description: "Cross-cutting decisions and page-specific Jira gates for the Host docs review. Hidden review page — not part of the docs nav; removed before merge." +"canonical": "/review-questions" +--- + + +Temporary review page (mirror of `REVIEW-QUESTIONS.md` on the PR). **Select any question text and click "💬 Comment on selection" to answer it** — your answer lands in the same feedback export as your page comments. The review panel also shows the Jira sources and blockers for whichever docs page you are viewing. + + +Start with the [Host docs review traceability audit](https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/REVIEW-TRACEABILITY.md) for the implemented-versus-open matrix and the CON-1519 bundle-ownership decisions needed in the meeting. + +The eight items below are cross-cutting **decisions, reviews, or confirmations**. A current Jira audit also found page-specific gates for account setup, Network & Ports, Self-Test, and Host Diagnostics. Those appear on the affected page in the port 4000 review panel. The first two items below unblock the review sequence. + +## Input 1. IA approval + +**Owner: Michele + docs owners · Round 0 · unblocks everything** + +Approve or modify the information architecture (CON-1518 decision points a–d): + +- (a) **Lifecycle sidebar** — Before You Host → Set Up → Verify & List → Operate → Reference, vs. some other top-level grouping. +- (b) **P0/P1/P2 priority order** — the ticket-volume-driven ordering of new docs. +- (c) **Supported Hardware as the #1 prevention doc** — even though it isn't the largest ticket bucket. +- (d) **Persona tags** — keep the visible chips (top-right of each page), restyle them, or drop to frontmatter-only convention. + +## Input 2. Review mechanics + +**Owner: Michele + docs owners** + +One small PR per sidebar group, or staged review of PR #185 with per-round checklists? PR #185 now contains all the work (former #153 is an ancestor of it). Either way, #152 and #153 should be closed as superseded. + +## Input 3. Pricing content review + +**Owner: Gobind / Solutions Engineering · Round 3 · CON-1256** + +Review the business/pricing positioning: [Pricing Your Listing](/host/pricing-your-listing), [Market Metrics](/host/market-metrics), and [Optimize Your Earnings](/host/optimization-guide). + +## Input 4. Machine-error platform behavior + +**Owner: Backend source owner (Hanran) · CON-1531** + +Seven confirmations that set the "how long it persists" copy on the [Machine Error Reference](/host/machine-errors): + +1. Is the 2026-06-24 error catalog complete for host-visible machine errors? +2. For each error, which field displays it to hosts (`error_msg`, `error_note`, `error_description`, `vm_error_msg`, `vm_error_level`, other)? +3. Which errors appear on the Machines page vs. only in a failed rental/instance detail? +4. For machine-deverifying errors, what clears the error — next clean heartbeat, successful self-test, admin action, time decay? +5. For VM-offer-only errors, what is the approximate clean-report/TTL before VM offers return? +6. Should logged-only rental-attempt messages be public host docs, or internal/support-only? +7. Should the console deep-link docs by raw error string, normalized category, or both? + +## Input 5. Installer Wizard screenshot + +**Owner: Product · Rounds 0/2** + +Approve the Host Installer Wizard (TUI) screenshot in [Installing Host Software](/host/installing-host-software#host-installer-wizard) — or supply a replacement asset. + +## Input 6. Supported Hardware sign-off + +**Owner: Product · Round 1** + +Confirm [Supported Hardware](/host/supported-hardware): exact GPU-family coverage, OS/cgroup guidance, and alignment of the CPU rule between docs, self-test #6, and vast-cli #413. Related product asks on the radar: payment/tax edge cases (incl. W-8 for non-US hosts) and datacenter requirements wording. + +## Input 7. Host Teams engineering answers + +**Owner: Engineering · Round 6 · CON-1581** + +Five answers that gate publishing [Host Teams](/host/host-teams): + +1. Individual→team migration: what happens to existing machines and accrued earnings? +2. Install-command `undefined` bug in team context — status? +3. What is the exact flow for granting machine-registration rights to a team API key? (Flagged inside the draft page itself.) +4. Are `billing_read`-only roles viable for host teams? +5. Who owns billing/payouts when machines move into a team? + +## Input 8. Persona scope ruling + +**Owner: Docs team · Round 5** + +Do the generated `host/cli/*` and `host/sdk/*` reference pages need persona chips, or are generated reference pages exempt? All 39 authored pages are tagged and chip-synced (lint-enforced); the 33 generated pages are currently exempt by convention. + +## Additional page-specific Jira gates + +Open the review panel on the affected page to see the exact questions, owner, +status, and direct Jira links. + +- **Account and installation:** [CON-1584](https://vastai.atlassian.net/browse/CON-1584), [CON-1581](https://vastai.atlassian.net/browse/CON-1581), and [CON-1518](https://vastai.atlassian.net/browse/CON-1518). +- **Network & Ports:** [CON-1514](https://vastai.atlassian.net/browse/CON-1514). +- **Verification / Self-Test:** confirm authoritative verification queue/wait-time facts. The generated reference, drift workflow, B300 cap, and older-GPU rules are present in PR #185. Review [CON-1515](https://vastai.atlassian.net/browse/CON-1515), [CON-1513](https://vastai.atlassian.net/browse/CON-1513), [CON-1583](https://vastai.atlassian.net/browse/CON-1583), and [CON-1419](https://vastai.atlassian.net/browse/CON-1419). +- **Host Diagnostics:** decide bundle intake, retention, triage, diagnostic ownership, and safe host-local artifact policy. The merged `vastai dump-logs` workflow is documented. Review [CON-1510](https://vastai.atlassian.net/browse/CON-1510), [CON-1519](https://vastai.atlassian.net/browse/CON-1519), and [CON-1514](https://vastai.atlassian.net/browse/CON-1514). + +--- + +*Non-blocking follow-ups already ticketed elsewhere: `vastai verify-status` / queue-position-by-GPU-tier (product/CLI feature), CLI error-string deep-linking (docs anchors are ready).* diff --git a/review-server.mjs b/review-server.mjs new file mode 100644 index 00000000..c7a2ba06 --- /dev/null +++ b/review-server.mjs @@ -0,0 +1,1627 @@ +#!/usr/bin/env node +/* + * Vast.ai docs review server — annotate the local Mintlify preview (PR #185) + * --------------------------------------------------------------------------- + * Zero dependencies. Node 18+. + * + * What it does: + * - Reverse-proxies the Mintlify dev preview (default http://localhost:3000) + * and injects a review overlay into every page. + * - Reviewers highlight text and leave comments; feedback autosaves to + * ./review-feedback/feedback-.json on this machine. + * - Exports Jira-importable CSV, plus Markdown and JSON, at any time. + * + * Usage: + * Terminal 1: npm run dev -- --no-open (Mintlify preview on :3000) + * Terminal 2: node review-server.mjs (review overlay on :4000) + * Browse: http://localhost:4000/host/hosting-overview + * + * Options: + * --port 4000 port for this review server + * --target http://localhost:3000 where the Mintlify preview runs + * --dir ./review-feedback where feedback JSON files are written + * + * Status & exports: http://localhost:4000/__review__/ + */ + +import http from 'node:http'; +import net from 'node:net'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +// ------------------------------------------------------------------ config +const argv = process.argv.slice(2); +function argValue(name, dflt) { + const i = argv.indexOf(name); + return i !== -1 && argv[i + 1] ? argv[i + 1] : dflt; +} +const PORT = parseInt(argValue('--port', '4000'), 10); +const TARGET = new URL(argValue('--target', 'http://localhost:3000')); +const FEEDBACK_DIR = path.resolve(argValue('--dir', './review-feedback')); +const PR_URL = 'https://github.com/vast-ai/docs/pull/185'; +const TRACEABILITY_URL = 'https://github.com/jjziets/docs/blob/CON-1584-host-cli-api-sdk/REVIEW-TRACEABILITY.md'; +const PR_LABEL = 'docs-pr185-review'; +const JIRA_BASE_URL = 'https://vastai.atlassian.net/browse/'; +const FEEDBACK_EXPORT_FORMAT = 'vast-docs-review-feedback'; +const FEEDBACK_EXPORT_VERSION = 1; +const MAX_IMPORT_ITEMS = 50000; + +// Review-only provenance. This data is served only by the :4000 review proxy; +// it is never injected into the Mintlify MDX served directly on :3000. +const JIRA_ISSUES = Object.freeze({ + 'CON-1187': { title: 'Host Docs ++', status: 'TO REVIEW' }, + 'CON-1509': { title: 'Self-Test Improvements', status: 'To Do' }, + 'CON-1584': { title: 'Host account docs and CLI/API/SDK intro', status: 'BLOCKED' }, + 'CON-1581': { title: 'Host Teams', status: 'BLOCKED' }, + 'CON-1531': { title: 'Machine Error Reference', status: 'BLOCKED' }, + 'CON-1518': { title: 'Host docs IA and sidebar restructure', status: 'TO REVIEW' }, + 'CON-1517': { title: 'Common Host questions and topics', status: 'TO REVIEW' }, + 'CON-1516': { title: 'Supported Hardware', status: 'DOING NOW' }, + 'CON-1515': { title: 'Verification / Self-Test Reference', status: 'TO REVIEW' }, + 'CON-1514': { title: 'Commonly reported Self-Test issues', status: 'TO REVIEW' }, + 'CON-1513': { title: 'Generate Verification docs from Self-Test code', status: 'TO REVIEW' }, + 'CON-1512': { title: 'Self-Test --ignore-requirements messaging', status: 'QA Passed' }, + 'CON-1510': { title: 'Self-Test explanations and error handling', status: 'TESTING' }, + 'CON-1502': { title: 'Fix Self-Test image family', status: 'QA Passed' }, + 'CON-1419': { title: 'Support older GPU architectures in Self-Test', status: 'TO REVIEW' }, + 'CON-1256': { title: 'Business, pricing, and listing optimization', status: 'TO REVIEW' }, + 'CON-1077': { title: 'Headless Hosting Guide', status: 'TO REVIEW' }, + 'CON-1583': { title: 'Lower Self-Test RAM requirement for B300', status: 'TO REVIEW' }, + 'CON-1519': { title: 'Self-Test diagnostic log bundles', status: 'TO REVIEW' }, +}); + +const PAGE_REVIEW_CONTEXTS = [ + { + paths: ['/host/hosting-overview', '/host/quickstart', '/host/persona-decision-guide'], + epics: ['CON-1187'], issues: ['CON-1518'], + blockers: [ + { issue: 'CON-1518', owner: 'Docs owners', question: 'Approve or modify the lifecycle sidebar and P0/P1/P2 ordering.' }, + { issue: 'CON-1518', owner: 'Docs owners', question: 'Keep, restyle, or remove the visible persona chips?' }, + ], + }, + { + paths: ['/host/account-hosting-agreement', '/host/account-security-for-hosts'], + epics: ['CON-1187'], issues: ['CON-1584', 'CON-1581'], + blockers: [ + { issue: 'CON-1584', owner: 'Product', question: 'Confirm that setup-page machine installation keys are account-specific and distinct from normal API keys.' }, + { issue: 'CON-1584', owner: 'Product', question: 'Confirm the dedicated-host-account guidance and escalation path for stale or wrong-account state.' }, + ], + }, + { + paths: ['/host/cli-api-sdk', '/host/fleet-operations'], prefixes: ['/host/cli/', '/host/sdk/'], + epics: ['CON-1187'], issues: ['CON-1584', 'CON-1518'], + blockers: [ + { issue: 'CON-1584', owner: 'Engineering', question: 'Confirm the exact team API-key flow and permissions for host registration and automation.' }, + { issue: 'CON-1518', owner: 'Docs owners', question: 'Do generated Host CLI/SDK reference pages require persona metadata, or are they exempt?' }, + ], + }, + { + paths: ['/host/host-teams'], + epics: ['CON-1187'], issues: ['CON-1581', 'CON-1584'], + blockers: [ + { issue: 'CON-1581', owner: 'Engineering', question: 'What happens to machines, accrued earnings, and payout history when an individual host moves to a team?' }, + { issue: 'CON-1581', owner: 'Engineering', question: 'Is the team-context undefined host-id/API-key installation issue fixed?' }, + { issue: 'CON-1581', owner: 'Engineering', question: 'What is the exact flow for granting machine registration rights to a team key or custom role?' }, + { issue: 'CON-1581', owner: 'Engineering', question: 'Can a custom role grant billing_read without machine_write?' }, + ], + }, + { + paths: ['/host/installing-host-software'], + epics: ['CON-1187'], issues: ['CON-1518', 'CON-1584', 'CON-1581'], + blockers: [ + { issue: 'CON-1518', owner: 'Product', question: 'Approve the Host Installer Wizard screenshot or provide a replacement asset.' }, + { issue: 'CON-1584', owner: 'Product', question: 'Confirm the public wording for setup-page machine installation keys.' }, + { issue: 'CON-1581', owner: 'Engineering', question: 'Confirm the correct team-context installation and registration-key flow.' }, + ], + }, + { + paths: ['/host/machine-errors'], + epics: ['CON-1187'], issues: ['CON-1531', 'CON-1517'], + blockers: [ + { issue: 'CON-1531', owner: 'Backend source owner', question: 'Is the host-visible machine-error catalog complete, and which fields/UI surfaces expose each error?' }, + { issue: 'CON-1531', owner: 'Backend source owner', question: 'What clears each error, including the actual heartbeat/TTL and Self-Test behavior?' }, + { issue: 'CON-1531', owner: 'Backend source owner', question: 'Which logged-only or admin-only errors are appropriate for public docs?' }, + { issue: 'CON-1531', owner: 'Product / Console', question: 'Should UI links target raw error strings, normalized categories, or both?' }, + ], + }, + { + paths: ['/host/common-errors-diagnostics'], + epics: ['CON-1187', 'CON-1509'], issues: ['CON-1531', 'CON-1517', 'CON-1519', 'CON-1514', 'CON-1510'], + blockers: [ + { issue: 'CON-1519', owner: 'Security / Engineering', question: 'Which host-local artifacts can be collected safely, and which must remain opt-in?' }, + { issue: 'CON-1514', owner: 'Backend', question: 'Which daemon, Docker, and port diagnostics can the backend expose directly?' }, + ], + }, + { + paths: ['/host/network-ports'], + epics: ['CON-1187', 'CON-1509'], issues: ['CON-1517', 'CON-1514'], + blockers: [ + { issue: 'CON-1514', owner: 'Backend / Networking', question: 'Confirm the per-GPU port requirement, TCP/UDP behavior, and per-instance versus total-host port-range wording.' }, + { issue: 'CON-1514', owner: 'Backend / Networking', question: 'Are reserved ports released on stop, destroy, or cleanup?' }, + { issue: 'CON-1514', owner: 'Backend / Networking', question: 'Can the backend expose the exact failed ports and protocol-level results?' }, + ], + }, + { + paths: ['/host/self-test-reference', '/host/how-to-self-test', '/host/understanding-verification', '/host/verification-stages'], + epics: ['CON-1187', 'CON-1509'], + issues: ['CON-1515', 'CON-1513', 'CON-1510', 'CON-1512', 'CON-1514', 'CON-1519', 'CON-1583', 'CON-1502', 'CON-1419'], + blockers: [ + { issue: 'CON-1515', owner: 'Product / Backend', question: 'Confirm the authoritative verification queue and wait-time facts.' }, + ], + }, + { + paths: ['/host/supported-hardware', '/host/hardware-prep'], + epics: ['CON-1187', 'CON-1509'], issues: ['CON-1516', 'CON-1583', 'CON-1419'], + blockers: [ + { issue: 'CON-1516', owner: 'Product', question: 'Confirm exact GPU-family coverage, OS/cgroup guidance, and the CPU rule shared by docs, CLI, and Self-Test.' }, + { issue: 'CON-1583', owner: 'Self-Test maintainers', question: 'Confirm how the B300 RAM cap should be explained to hosts.' }, + ], + }, + { + paths: ['/host/pricing-your-listing', '/host/market-metrics', '/host/optimization-guide', '/host/earning', '/host/payment', '/host/datacenter-status', '/host/guide-to-taxes'], + epics: ['CON-1187'], issues: ['CON-1256'], + blockers: [ + { issue: 'CON-1256', owner: 'Solutions Engineering / Business', question: 'Review pricing, sales-process, datacenter, Secure Cloud, payment, and tax positioning.' }, + { issue: 'CON-1256', owner: 'Docs owners', question: 'Confirm Gobind as reviewer and content owner for this page family.' }, + ], + }, + { + paths: ['/host/headless-install'], + epics: ['CON-1187'], issues: ['CON-1077'], blockers: [], + }, + { + paths: ['/host/common-host-questions'], + epics: ['CON-1187'], issues: ['CON-1517'], + blockers: [], + }, + { + paths: ['/review-questions'], + epics: ['CON-1187', 'CON-1509'], + issues: ['CON-1584', 'CON-1581', 'CON-1531', 'CON-1518', 'CON-1517', 'CON-1515', 'CON-1514', 'CON-1513', 'CON-1510', 'CON-1256'], + blockers: [], + }, +]; + +function normalizeReviewPath(rawPath) { + let pathname = String(rawPath || '/').split(/[?#]/, 1)[0] || '/'; + try { pathname = decodeURIComponent(pathname); } catch { /* keep raw safe path */ } + if (!pathname.startsWith('/')) pathname = '/' + pathname; + if (pathname.length > 1) pathname = pathname.replace(/\/+$/, ''); + return pathname; +} + +function issueDetails(key) { + const issue = JIRA_ISSUES[key] || { title: key, status: '' }; + return { key, title: issue.title, status: issue.status, url: JIRA_BASE_URL + encodeURIComponent(key) }; +} + +function reviewContextForPath(rawPath) { + const pathname = normalizeReviewPath(rawPath); + const matched = PAGE_REVIEW_CONTEXTS.filter((entry) => + (entry.paths || []).includes(pathname) || (entry.prefixes || []).some((prefix) => pathname.startsWith(prefix)) + ); + const epicKeys = []; + const issueKeys = []; + const blockers = []; + const seenBlockers = new Set(); + const addUnique = (list, value) => { if (value && !list.includes(value)) list.push(value); }; + + if (pathname.startsWith('/host/') && !matched.length) addUnique(epicKeys, 'CON-1187'); + for (const entry of matched) { + for (const key of entry.epics || []) addUnique(epicKeys, key); + for (const key of entry.issues || []) addUnique(issueKeys, key); + for (const blocker of entry.blockers || []) { + const fingerprint = `${blocker.issue || ''}\n${blocker.question || ''}`; + if (seenBlockers.has(fingerprint)) continue; + seenBlockers.add(fingerprint); + blockers.push({ + question: blocker.question, + owner: blocker.owner || '', + issue: blocker.issue ? issueDetails(blocker.issue) : null, + }); + } + } + return { + pathname, + matched: matched.length > 0, + epics: epicKeys.map(issueDetails), + issues: issueKeys.map(issueDetails), + blockers, + }; +} + +fs.mkdirSync(FEEDBACK_DIR, { recursive: true }); + +// ------------------------------------------------------------- feedback io +function reviewerSlug(name) { + const raw = String(name || '').trim(); + const s = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + // short hash keeps distinct names ("Ana B" vs "ana-b", non-ASCII names) in distinct files + const h = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 6); + return (s || 'reviewer') + '-' + h; +} +function feedbackFile(reviewer) { + return path.join(FEEDBACK_DIR, `feedback-${reviewerSlug(reviewer)}.json`); +} +function readReviewerState(reviewer) { + try { + const raw = fs.readFileSync(feedbackFile(reviewer), 'utf8'); + const data = JSON.parse(raw); + return Array.isArray(data.items) ? data.items : []; + } catch { + return []; + } +} +function mergeById(existing, incoming) { + const byId = new Map(); + for (const it of existing) if (it && typeof it.id === 'string') byId.set(it.id, it); + for (const it of incoming) { + if (!it || typeof it.id !== 'string') continue; + const cur = byId.get(it.id); + if (!cur || String(it.updatedAt || '') >= String(cur.updatedAt || '')) byId.set(it.id, it); + } + return [...byId.values()]; +} +function writeReviewerState(reviewer, items) { + // per-item merge (newest updatedAt wins) so concurrent tabs/late writers never clobber each other + const merged = mergeById(readReviewerState(reviewer), items); + const file = feedbackFile(reviewer); + const payload = { reviewer, updatedAt: new Date().toISOString(), pr: PR_URL, items: merged }; + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(payload, null, 2)); + fs.renameSync(tmp, file); + return merged.length; +} +function readAllItems() { + const byId = new Map(); + const reviewers = []; + for (const f of fs.readdirSync(FEEDBACK_DIR)) { + if (!/^feedback-.*\.json$/.test(f)) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(FEEDBACK_DIR, f), 'utf8')); + if (Array.isArray(data.items)) { + // dedupe across files by item id (a reviewer renaming mid-session leaves copies in + // both files) — keep the newest version, tombstones included so deletes win + for (const it of data.items) { + if (!it || typeof it.id !== 'string') continue; + const cur = byId.get(it.id); + if (!cur || String(it.updatedAt || '') >= String(cur.updatedAt || '')) byId.set(it.id, it); + } + reviewers.push(data.reviewer || f); + } + } catch { /* skip unreadable file */ } + } + const all = [...byId.values()].filter((it) => !it.deleted); + all.sort((a, b) => (a.page || '').localeCompare(b.page || '') || (a.createdAt || '').localeCompare(b.createdAt || '')); + return { items: all, reviewers }; +} + +function feedbackExportPayload() { + const { items, reviewers } = readAllItems(); + return { + format: FEEDBACK_EXPORT_FORMAT, + version: FEEDBACK_EXPORT_VERSION, + generatedAt: new Date().toISOString(), + pr: PR_URL, + reviewers, + items, + }; +} + +function importFeedbackPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('expected a JSON feedback export object'); + } + if (payload.format != null && payload.format !== FEEDBACK_EXPORT_FORMAT) { + throw new Error(`unsupported feedback format: ${String(payload.format)}`); + } + if (payload.version != null && payload.version !== FEEDBACK_EXPORT_VERSION) { + throw new Error(`unsupported feedback version: ${String(payload.version)}`); + } + if (!Array.isArray(payload.items)) throw new Error('expected an items[] array'); + if (payload.items.length > MAX_IMPORT_ITEMS) { + throw new Error(`too many feedback items (maximum ${MAX_IMPORT_ITEMS})`); + } + + const fallbackReviewer = typeof payload.reviewer === 'string' ? payload.reviewer.trim() : ''; + const groups = new Map(); + const seenIds = new Set(); + for (let index = 0; index < payload.items.length; index += 1) { + const item = payload.items[index]; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new Error(`item ${index + 1} must be an object`); + } + const id = typeof item.id === 'string' ? item.id.trim() : ''; + if (!id || id.length > 200) throw new Error(`item ${index + 1} has an invalid id`); + if (seenIds.has(id)) throw new Error(`duplicate feedback item id: ${id}`); + seenIds.add(id); + const reviewer = typeof item.reviewer === 'string' && item.reviewer.trim() + ? item.reviewer.trim() + : fallbackReviewer; + if (!reviewer || reviewer.length > 200) { + throw new Error(`item ${index + 1} is missing a valid reviewer`); + } + if (item.updatedAt != null && typeof item.updatedAt !== 'string') { + throw new Error(`item ${index + 1} has an invalid updatedAt`); + } + const normalized = { ...item, id, reviewer }; + if (!groups.has(reviewer)) groups.set(reviewer, []); + groups.get(reviewer).push(normalized); + } + + const states = []; + for (const [reviewer, items] of groups) { + states.push({ reviewer, received: items.length, stored: writeReviewerState(reviewer, items) }); + } + return { + ok: true, + imported: payload.items.length, + reviewerCount: states.length, + reviewers: states, + }; +} + +// ---------------------------------------------------------------- exports +const PRIORITY_MAP = { Blocker: 'Highest', Major: 'High', Minor: 'Medium', Nit: 'Low' }; + +function csvCell(v) { + let s = String(v ?? ''); + // formula-injection guard; +/- only when they can start a formula/number so that + // ordinary quoted text like "--target http://..." is not corrupted by the apostrophe + if (/^[=@\t\r]/.test(s) || /^[+-][\d.=]/.test(s)) s = "'" + s; + if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'; + return s; +} +function firstLine(s) { + return String(s || '').split(/\r?\n/)[0].trim(); +} +function itemSummary(it) { + const page = (it.page || '/').replace(/^\/host\//, '').replace(/^\//, '') || 'home'; + let head = firstLine(it.comment).slice(0, 90); + if (!head) head = (it.quote || '').slice(0, 90); + return `[${page}] ${it.category || 'Feedback'}: ${head}`; +} +function itemDescription(it) { + const lines = []; + lines.push(`Docs review feedback on PR 185 (${PR_URL})`); + lines.push(''); + lines.push(`Page: ${it.page || '/'}${it.pageTitle ? ' — ' + it.pageTitle : ''}`); + if (it.heading) lines.push(`Section: ${it.heading}`); + if (it.quote) { + lines.push(''); + lines.push('Quoted text:'); + lines.push(`"${it.quote}"`); + } + lines.push(''); + lines.push('Comment:'); + lines.push(it.comment || '(no comment)'); + lines.push(''); + lines.push(`Category: ${it.category || '-'} | Severity: ${it.severity || '-'} | Status: ${it.status || 'open'}`); + lines.push(`Reviewer: ${it.reviewer || '-'} | Created: ${it.createdAt || '-'}`); + return lines.join('\n'); +} +function toCsv(items) { + const header = ['Summary', 'Issue Type', 'Priority', 'Labels', 'Description', 'Page', 'Section', 'Quote', 'Category', 'Severity', 'Status', 'Reviewer', 'Created']; + const rows = [header.map(csvCell).join(',')]; + for (const it of items) { + rows.push([ + itemSummary(it), + 'Task', + PRIORITY_MAP[it.severity] || 'Medium', + PR_LABEL, + itemDescription(it), + it.page || '', + it.heading || '', + it.quote || '', + it.category || '', + it.severity || '', + it.status || 'open', + it.reviewer || '', + it.createdAt || '', + ].map(csvCell).join(',')); + } + return '' + rows.join('\r\n') + '\r\n'; +} +function toMarkdown(items, reviewers) { + const out = []; + out.push('# Vast.ai docs review feedback — PR 185'); + out.push(''); + out.push(`Generated ${new Date().toISOString()} · ${items.length} item(s) · reviewers: ${reviewers.join(', ') || '—'}`); + out.push(`PR: ${PR_URL}`); + const byPage = new Map(); + for (const it of items) { + const key = it.page || '/'; + if (!byPage.has(key)) byPage.set(key, []); + byPage.get(key).push(it); + } + for (const [page, list] of byPage) { + out.push(''); + out.push(`## ${page}${list[0].pageTitle ? ' — ' + list[0].pageTitle : ''}`); + let n = 0; + for (const it of list) { + n += 1; + out.push(''); + out.push(`### ${n}. [${it.severity || '-'} · ${it.category || '-'}]${it.heading ? ' ' + it.heading : ''}${it.status === 'resolved' ? ' (resolved)' : ''}`); + if (it.quote) out.push(`> ${String(it.quote).replace(/\r?\n/g, '\n> ')}`); + out.push(''); + out.push(it.comment || '(no comment)'); + out.push(''); + out.push(`*— ${it.reviewer || 'unknown'}, ${it.createdAt || ''}*`); + } + } + out.push(''); + return out.join('\n'); +} + +// ------------------------------------------------------------ status page +function esc(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +function statusPage() { + const { items, reviewers } = readAllItems(); + const open = items.filter((i) => i.status !== 'resolved').length; + const byReviewer = {}; + for (const it of items) byReviewer[it.reviewer || '?'] = (byReviewer[it.reviewer || '?'] || 0) + 1; + const rows = Object.entries(byReviewer) + .map(([r, n]) => `${esc(r)}${n}`).join('') || 'No feedback yet'; + return `PR 185 review feedback + +

Vast.ai docs review — PR 185 feedback

+

${items.length} item(s), ${open} open. Feedback files live in ${esc(FEEDBACK_DIR)}.

+${rows}
ReviewerItems
+

+Save JSON + + +

+

JSON is the restorable backup for every page and reviewer. Import merges it with current feedback; newer item timestamps win.

+

Other exports: CSV (Jira import) · Markdown

+

+

Reviewer inputs and Jira sources are shown for each page inside the review panel. The combined list remains available at /review-questions, with the implemented-versus-open evidence in the traceability audit.

+

When you're done reviewing, send the JSON file back to restore all feedback, or use the CSV for Jira import.

+

← Back to the docs preview

+ +`; +} + +// ---------------------------------------------------------------- overlay +// Client-side overlay source. Served at /__review__/overlay.js. +// NOTE: written without backticks or "$"+"{" so it can live in String.raw. +const OVERLAY_JS = String.raw` +(function () { + 'use strict'; + if (window.__vastReviewLoaded) return; + window.__vastReviewLoaded = true; + + var API = '/__review__/api'; + var LS_REVIEWER = 'vastReview.reviewer'; + var LS_ITEMS = 'vastReview.items'; + var CATEGORIES = ['Error', 'Unclear', 'Missing', 'Outdated', 'Suggestion', 'Question', 'Praise']; + var SEVERITIES = ['Blocker', 'Major', 'Minor', 'Nit']; + var SEV_COLOR = { Blocker: '#d92d20', Major: '#e8590c', Minor: '#b58a00', Nit: '#5c677d' }; + + // ---------------- state ---------------- + var reviewer = ''; + var items = []; + var pending = null; // selection captured but not yet saved + var selectionDraft = null; // latest page selection, kept until commented on or cleared + var editingId = null; + var showAllPages = false; + var saveStatus = 'idle'; // idle | saving | saved | offline + var saveTimer = null; + var anchorTimer = null; + var selectionTimer = null; + var contextRequest = 0; + var pageContext = { epics: [], issues: [], blockers: [] }; + + try { reviewer = localStorage.getItem(LS_REVIEWER) || ''; } catch (e) {} + try { items = JSON.parse(localStorage.getItem(LS_ITEMS) || '[]'); } catch (e) { items = []; } + if (!Array.isArray(items)) items = []; + + function uid() { return 'fb-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); } + function nowIso() { return new Date().toISOString(); } + function esc(s) { + return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function pageTitle() { + var t = document.title || ''; + return t.replace(/\s*[-|–—]\s*Vast.*$/i, '').trim() || t; + } + function visibleItems() { return items.filter(function (it) { return !it.deleted; }); } + function pageItems() { + return visibleItems().filter(function (it) { return it.page === location.pathname; }); + } + + // ---------------- persistence ---------------- + function persistLocal() { + try { localStorage.setItem(LS_ITEMS, JSON.stringify(items)); } catch (e) {} + } + function scheduleSave() { + persistLocal(); + renderBadge(); + if (saveTimer) clearTimeout(saveTimer); + saveStatus = 'saving'; + renderSaveStatus(); + saveTimer = setTimeout(pushToServer, 700); + } + function pushToServer() { + if (!reviewer) { saveStatus = 'offline'; renderSaveStatus(); return Promise.resolve(false); } + return fetch(API + '/state', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reviewer: reviewer, items: items }) + }).then(function (r) { + saveStatus = r.ok ? 'saved' : 'offline'; + renderSaveStatus(); + return r.ok; + }).catch(function () { + saveStatus = 'offline'; + renderSaveStatus(); + return false; + }); + } + function mergeServerState() { + if (!reviewer) return; + fetch(API + '/state?reviewer=' + encodeURIComponent(reviewer)) + .then(function (r) { return r.json(); }) + .then(function (data) { + var server = (data && Array.isArray(data.items)) ? data.items : []; + var byId = {}; + items.forEach(function (it) { byId[it.id] = it; }); + server.forEach(function (s) { + var mine = byId[s.id]; + if (!mine || String(s.updatedAt || '') > String(mine.updatedAt || '')) byId[s.id] = s; + }); + items = Object.keys(byId).map(function (k) { return byId[k]; }); + persistLocal(); + scheduleAnchor(); + renderAll(); + scheduleSave(); // push items that so far existed only in this browser back to disk + }) + .catch(function () {}); + } + function flushNow() { + // best-effort synchronous save on tab close / navigation away + if (!reviewer || !items.length) return; + if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + persistLocal(); + try { + var blob = new Blob([JSON.stringify({ reviewer: reviewer, items: items })], { type: 'application/json' }); + navigator.sendBeacon(API + '/state', blob); + } catch (e) {} + } + window.addEventListener('pagehide', flushNow); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') flushNow(); + }); + + function saveJsonBackup() { + if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + persistLocal(); + var sync = reviewer ? pushToServer() : Promise.resolve(true); + sync.then(function (ok) { + if (!ok && reviewer) toast('Browser feedback could not sync; saving the server backup'); + var link = document.createElement('a'); + link.href = '/__review__/export/feedback.json'; + link.download = 'pr185-docs-feedback.json'; + document.body.appendChild(link); + link.click(); + link.remove(); + if (ok || !reviewer) toast('Saved JSON backup'); + }); + } + + function importJsonBackup(file) { + if (!file) return; + file.text().then(function (text) { + var payload = JSON.parse(text); + var count = payload && Array.isArray(payload.items) ? payload.items.length : 0; + if (!confirm('Import ' + count + ' feedback item(s)? Existing newer items will be kept.')) return null; + var sync = reviewer ? pushToServer() : Promise.resolve(true); + return sync.then(function () { + return fetch(API + '/import', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) + }); + }); + }).then(function (response) { + if (!response) return null; + return response.json().then(function (data) { + if (!response.ok) throw new Error(data.error || 'Import failed'); + toast('Imported ' + data.imported + ' item(s) for ' + data.reviewerCount + ' reviewer(s)'); + if (reviewer) mergeServerState(); + return data; + }); + }).catch(function (error) { + toast('Import failed: ' + String(error && error.message ? error.message : error)); + }); + } + + // ---------------- text index + anchoring ---------------- + function skipNode(el) { + if (!el) return false; + var tag = el.nodeName; + return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE' || el.id === '__vast_review_host__'; + } + function buildTextIndex() { + var nodes = []; + var text = ''; + var visCache = new Map(); + function isRendered(el) { + if (visCache.has(el)) return visCache.get(el); + var ok = false; + if (el.getClientRects().length > 0) { + var r = el.getBoundingClientRect(); + // zero/1px boxes catch sr-only clip patterns as well as display:none + ok = r.width > 1.5 && r.height > 1.5 && getComputedStyle(el).visibility !== 'hidden'; + } + visCache.set(el, ok); + return ok; + } + var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode: function (node) { + var p = node.parentNode; + while (p && p !== document.body) { + if (p.nodeType === 1 && skipNode(p)) return NodeFilter.FILTER_REJECT; + p = p.parentNode; + } + var el = node.parentElement; + if (el && node.data.trim() && !isRendered(el)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + var n; + while ((n = walker.nextNode())) { + nodes.push({ node: n, start: text.length, end: text.length + n.data.length }); + text += n.data; + } + return { text: text, nodes: nodes }; + } + function globalOffsetOf(container, offset, index) { + if (container.nodeType === 3) { + for (var i = 0; i < index.nodes.length; i++) { + if (index.nodes[i].node === container) return index.nodes[i].start + offset; + } + } + return -1; + } + function nodeAt(index, globalPos, preferStart) { + // binary search for the text node containing globalPos + var lo = 0, hi = index.nodes.length - 1; + while (lo <= hi) { + var mid = (lo + hi) >> 1; + var e = index.nodes[mid]; + if (globalPos < e.start) hi = mid - 1; + else if (globalPos > e.end || (globalPos === e.end && preferStart && mid + 1 < index.nodes.length)) lo = mid + 1; + else return { node: e.node, offset: globalPos - e.start }; + } + return null; + } + function commonSuffixLen(a, b) { + var n = 0; + while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++; + return n; + } + function findQuote(index, quote, prefix) { + if (!quote) return null; + var hits = []; + var from = 0, at; + while ((at = index.text.indexOf(quote, from)) !== -1) { + hits.push({ start: at, end: at + quote.length }); + from = at + 1; + if (hits.length > 50) break; + } + if (!hits.length) { + // whitespace-tolerant fallback + var pattern = quote.replace(/[.*+?^$()\[\]{}|\\]/g, '\\$&').replace(/\s+/g, '\\s*'); + try { + var m = new RegExp(pattern).exec(index.text); + if (m) hits.push({ start: m.index, end: m.index + m[0].length }); + } catch (e) {} + } + if (!hits.length) return null; + if (hits.length === 1 || !prefix) return hits[0]; + var best = hits[0], bestScore = -1; + hits.forEach(function (h) { + var before = index.text.slice(Math.max(0, h.start - prefix.length), h.start); + var score = commonSuffixLen(before, prefix); + if (score > bestScore) { bestScore = score; best = h; } + }); + return best; + } + function makeRange(index, start, end) { + var s = nodeAt(index, start, true); + var e = nodeAt(index, end, false); + if (!s || !e) return null; + try { + var r = document.createRange(); + r.setStart(s.node, s.offset); + r.setEnd(e.node, e.offset); + return r; + } catch (err) { return null; } + } + + var anchoredRanges = {}; // id -> Range + function anchorAll() { + anchoredRanges = {}; + var list = pageItems().filter(function (it) { return it.type === 'inline'; }); + if (list.length) { + var index = buildTextIndex(); + list.forEach(function (it) { + var hit = findQuote(index, it.quote, it.prefix); + if (hit) { + var r = makeRange(index, hit.start, hit.end); + if (r) anchoredRanges[it.id] = r; + } + }); + } + paintHighlights(); + renderList(); + } + function scheduleAnchor() { + if (anchorTimer) clearTimeout(anchorTimer); + anchorTimer = setTimeout(anchorAll, 400); + } + function paintHighlights() { + if (typeof Highlight === 'undefined' || !CSS.highlights) return; + var ranges = Object.keys(anchoredRanges).map(function (k) { return anchoredRanges[k]; }); + if (ranges.length) { + var hl = new Highlight(); + ranges.forEach(function (r) { hl.add(r); }); + CSS.highlights.set('vast-review', hl); + } else { + CSS.highlights.delete('vast-review'); + } + } + function flashRange(r) { + if (typeof Highlight === 'undefined' || !CSS.highlights) return; + CSS.highlights.set('vast-review-flash', new Highlight(r)); + setTimeout(function () { CSS.highlights.delete('vast-review-flash'); }, 1600); + } + + // ---------------- selection capture ---------------- + function hideSelBtn() { selBtn.style.display = 'none'; } + function clearSelectionDraft() { + selectionDraft = null; + hideSelBtn(); + renderSelectionDraft(); + } + function positionSelectionButton(rect) { + // The panel has its own persistent selection action. Avoid placing the + // transient button underneath it when a reviewer selects text nearby. + if (panel && panel.classList.contains('open')) { hideSelBtn(); return; } + selBtn.style.visibility = 'hidden'; + selBtn.style.display = 'flex'; + var width = selBtn.offsetWidth || 190; + var height = selBtn.offsetHeight || 36; + var x = rect.left + (rect.width / 2) - (width / 2); + var y = rect.bottom + 8; + x = Math.max(8, Math.min(x, window.innerWidth - width - 8)); + if (y + height > window.innerHeight - 8) y = rect.top - height - 8; + y = Math.max(8, Math.min(y, window.innerHeight - height - 8)); + selBtn.style.left = x + 'px'; + selBtn.style.top = y + 'px'; + selBtn.style.visibility = 'visible'; + } + function onSelectionSettled() { + var sel = document.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { hideSelBtn(); return; } + var anchor = sel.anchorNode; + var anchorRoot = anchor && anchor.getRootNode ? anchor.getRootNode() : null; + if (anchorRoot === shadow) { hideSelBtn(); return; } + if (anchor && host.contains(anchor.nodeType === 1 ? anchor : anchor.parentNode)) { hideSelBtn(); return; } + if (anchor === host) { hideSelBtn(); return; } + var quote = sel.toString().replace(/\s+$/,'').replace(/^\s+/,''); + if (quote.length < 2 || quote.length > 2000) { hideSelBtn(); return; } + var range = sel.getRangeAt(0); + var rects = range.getClientRects(); + var rect = rects.length ? rects[rects.length - 1] : range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { hideSelBtn(); return; } + + var index = buildTextIndex(); + var s = globalOffsetOf(range.startContainer, range.startOffset, index); + var e = globalOffsetOf(range.endContainer, range.endOffset, index); + var prefix = '', suffix = ''; + if (s >= 0) prefix = index.text.slice(Math.max(0, s - 40), s); + if (e >= 0) suffix = index.text.slice(e, e + 40); + + selectionDraft = { + quote: quote, prefix: prefix, suffix: suffix, + heading: nearestHeading(range) + }; + renderSelectionDraft(); + positionSelectionButton(rect); + } + function nearestHeading(range) { + var hs = document.querySelectorAll('h1, h2, h3, h4'); + var best = ''; + for (var i = 0; i < hs.length; i++) { + var pos = range.startContainer.compareDocumentPosition(hs[i]); + if (pos & Node.DOCUMENT_POSITION_PRECEDING) best = hs[i].textContent.trim(); + } + return best; + } + + // ---------------- item ops ---------------- + var composerCtx = null; // page identity captured when the composer opens, so a + // back/forward navigation mid-typing cannot mislabel the item + function saveItem(fields) { + var it; + var consumedSelection = false; + if (editingId) { + it = items.filter(function (x) { return x.id === editingId; })[0]; + if (!it) return; + it.category = fields.category; + it.severity = fields.severity; + it.comment = fields.comment; + it.updatedAt = nowIso(); + } else { + consumedSelection = !!(pending && pending.quote); + it = { + id: uid(), + reviewer: reviewer, + page: composerCtx ? composerCtx.page : location.pathname, + pageTitle: composerCtx ? composerCtx.pageTitle : pageTitle(), + type: pending && pending.quote ? 'inline' : 'page', + quote: pending ? (pending.quote || '') : '', + prefix: pending ? (pending.prefix || '') : '', + suffix: pending ? (pending.suffix || '') : '', + heading: pending ? (pending.heading || '') : '', + category: fields.category, + severity: fields.severity, + comment: fields.comment, + status: 'open', + createdAt: nowIso(), + updatedAt: nowIso() + }; + items.push(it); + } + pending = null; + editingId = null; + if (consumedSelection) clearSelectionDraft(); + scheduleSave(); + scheduleAnchor(); + renderAll(); + toast('Feedback saved'); + } + function deleteItem(id) { + var it = items.filter(function (x) { return x.id === id; })[0]; + if (!it) return; + it.deleted = true; + it.updatedAt = nowIso(); + scheduleSave(); + scheduleAnchor(); + renderAll(); + } + function toggleResolve(id) { + var it = items.filter(function (x) { return x.id === id; })[0]; + if (!it) return; + it.status = it.status === 'resolved' ? 'open' : 'resolved'; + it.updatedAt = nowIso(); + scheduleSave(); + renderAll(); + } + + // ---------------- UI ---------------- + var host = document.createElement('div'); + host.id = '__vast_review_host__'; + var shadow = host.attachShadow({ mode: 'open' }); + + // document-level styles (::highlight can't live in shadow DOM) + var docStyle = document.createElement('style'); + docStyle.textContent = '::highlight(vast-review){background:rgba(255,200,50,.45);}' + + '::highlight(vast-review-flash){background:rgba(74,92,240,.35);}'; + document.head.appendChild(docStyle); + + var css = '' + + ':host{all:initial}' + + '*{box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif}' + + '#pill{position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;align-items:center;gap:8px;' + + 'padding:10px 16px;border:none;border-radius:999px;background:#4a5cf0;color:#fff;font-size:14px;font-weight:600;' + + 'cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.25)}' + + '#pill:hover{background:#3a49d6}' + + '#pill.selection-ready{background:#1a1a2e;box-shadow:0 0 0 3px rgba(255,209,102,.85),0 4px 16px rgba(0,0,0,.25)}' + + '#pill .count{background:rgba(255,255,255,.25);border-radius:999px;padding:1px 8px;font-size:12px}' + + '#selbtn{position:fixed;z-index:2147483001;display:none;align-items:center;gap:6px;padding:7px 12px;' + + 'border:none;border-radius:8px;background:#1a1a2e;color:#fff;font-size:13px;font-weight:600;cursor:pointer;' + + 'box-shadow:0 0 0 3px rgba(255,209,102,.85),0 4px 14px rgba(0,0,0,.3);white-space:nowrap}' + + '#panel{position:fixed;top:0;right:0;bottom:0;width:380px;max-width:95vw;z-index:2147483002;display:none;' + + 'flex-direction:column;background:#fff;color:#1a1a2e;border-left:1px solid #d5d9e4;box-shadow:-6px 0 24px rgba(0,0,0,.12);font-size:13px}' + + '#panel.open{display:flex}' + + '#panel header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#1a1a2e;color:#fff}' + + '#panel header b{font-size:14px}' + + '#panel header button{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1}' + + '.meta{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid #e7eaf1;color:#5c677d}' + + '.meta b{color:#1a1a2e}' + + '.meta button,.filters button{background:#f0f2f8;border:1px solid #d5d9e4;border-radius:6px;padding:3px 9px;cursor:pointer;font-size:12px}' + + '.filters{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 14px;border-bottom:1px solid #e7eaf1}' + + '.filters label{display:flex;align-items:center;gap:6px;cursor:pointer;color:#5c677d}' + + '.filters .primary{background:#4a5cf0;border-color:#4a5cf0;color:#fff;font-weight:600}' + + '.context-count{background:#fff1b8;color:#6b4f00;border-radius:999px;padding:1px 7px;font-size:11px;font-weight:800}' + + '#jiraContext{padding:10px 14px;border-bottom:1px solid #e7eaf1;background:#f8f9fc;color:#384056}' + + '#jiraContext[hidden]{display:none}' + + '.jira-title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px}' + + '.jira-title b{color:#1a1a2e;font-size:12px}' + + '.jira-title a{font-size:11px;color:#4a5cf0;text-decoration:none;font-weight:700}' + + '.jira-links{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:7px}' + + '.jira-link{display:inline-flex;align-items:center;gap:4px;border:1px solid #c9d0e2;border-radius:999px;padding:3px 7px;' + + 'background:#fff;color:#34405a;text-decoration:none;font-size:11px;font-weight:700}' + + '.jira-link.epic{border-color:#8d9af1;background:#f0f2ff;color:#3446ba}' + + '.jira-link.blocked{border-color:#efaaa5;background:#fff2f0;color:#a12620}' + + '.jira-status{font-size:9px;font-weight:700;opacity:.72;text-transform:uppercase}' + + '.jira-blockers{margin-top:6px;border:1px solid #efd28a;border-radius:8px;background:#fff9e8;padding:7px 9px}' + + '.jira-blockers summary{cursor:pointer;color:#6b4f00;font-weight:800;font-size:11px}' + + '.jira-blockers ul{margin:7px 0 0;padding-left:18px}' + + '.jira-blockers li{margin:0 0 7px;line-height:1.35;color:#5c5233}' + + '.jira-blockers li:last-child{margin-bottom:0}' + + '.jira-blockers a{color:#4a5cf0;text-decoration:none;font-weight:800;white-space:nowrap}' + + '.jira-owner{display:block;color:#8a6f2f;font-size:10px;margin-top:2px}' + + '.jira-clear{font-size:11px;color:#687188}' + + '#selectionTools{padding:10px 14px;border-bottom:1px solid #e7eaf1;background:#f7f8ff}' + + '#selectionTools .selection-empty{color:#5c677d;line-height:1.45}' + + '#selectionTools .selection-ready-body{display:none;gap:8px;flex-direction:column}' + + '#selectionTools.ready .selection-empty{display:none}' + + '#selectionTools.ready .selection-ready-body{display:flex}' + + '.selection-title{display:flex;align-items:center;justify-content:space-between;gap:8px}' + + '.selection-title b{color:#1a1a2e}' + + '.selection-title button{border:0;background:none;color:#5c677d;cursor:pointer;font-size:12px;padding:0}' + + '#selectionQuote{margin:0;padding:6px 10px;border-left:3px solid #ffd166;background:#fff9e8;color:#5c5233;' + + 'font-style:italic;max-height:72px;overflow:auto;white-space:pre-wrap}' + + '#commentSelection{align-self:flex-start;border:0;border-radius:7px;padding:7px 11px;background:#4a5cf0;color:#fff;' + + 'font-size:12px;font-weight:700;cursor:pointer}' + + '#list{flex:1;overflow-y:auto;padding:10px 14px}' + + '.pagegroup{margin:14px 0 6px;font-weight:700;font-size:12px;color:#5c677d;text-transform:uppercase;letter-spacing:.4px}' + + '.card{border:1px solid #e0e4ee;border-radius:10px;padding:10px 12px;margin-bottom:10px;background:#fbfcfe}' + + '.card.resolved{opacity:.55}' + + '.card .chips{display:flex;gap:6px;align-items:center;margin-bottom:6px;flex-wrap:wrap}' + + '.chip{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;color:#fff}' + + '.chip.cat{background:#5c677d}' + + '.chip.orphan{background:#fff;color:#b58a00;border:1px dashed #b58a00}' + + '.card blockquote{margin:6px 0;padding:4px 10px;border-left:3px solid #ffd166;background:#fffbeb;color:#5c5233;' + + 'font-style:italic;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}' + + '.card .comment{white-space:pre-wrap;margin:6px 0}' + + '.card .byline{color:#8a93a6;font-size:11px;margin-top:4px}' + + '.card .acts{display:flex;gap:10px;margin-top:8px}' + + '.card .acts button{background:none;border:none;padding:0;color:#4a5cf0;font-size:12px;cursor:pointer;font-weight:600}' + + '.card .acts button.danger{color:#d92d20}' + + '.empty{color:#8a93a6;text-align:center;padding:30px 10px}' + + '#panel footer{border-top:1px solid #e7eaf1;padding:10px 14px;display:flex;flex-direction:column;gap:8px;background:#f7f8fc}' + + '.exports{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' + + '.exports a,.exports button{color:#4a5cf0;background:#fff;font-weight:600;text-decoration:none;font:600 12px system-ui;border:1px solid #c9d0f5;border-radius:6px;padding:4px 9px;cursor:pointer}' + + '.exports .primary{background:#4a5cf0;color:#fff;border-color:#4a5cf0}' + + '.exports .label{font-size:11px;color:#687086}' + + '#saveStatus{font-size:11px;color:#8a93a6}' + + '#composer,#nameModal{position:fixed;z-index:2147483003;top:50%;left:50%;transform:translate(-50%,-50%);width:420px;max-width:92vw;' + + 'display:none;flex-direction:column;gap:10px;background:#fff;color:#1a1a2e;border-radius:14px;padding:18px;box-shadow:0 12px 48px rgba(0,0,0,.3);font-size:13px}' + + '#composer.open,#nameModal.open{display:flex}' + + '#composer h3,#nameModal h3{margin:0;font-size:15px}' + + '#composer blockquote{margin:0;padding:6px 10px;border-left:3px solid #ffd166;background:#fffbeb;color:#5c5233;font-style:italic;' + + 'max-height:70px;overflow:auto}' + + '.row{display:flex;gap:10px}' + + '.row label{flex:1;display:flex;flex-direction:column;gap:4px;font-weight:600;color:#5c677d;font-size:11px;text-transform:uppercase}' + + 'select,textarea,input[type=text]{border:1px solid #c9cfdd;border-radius:8px;padding:8px;font-size:13px;width:100%;background:#fff;color:#1a1a2e}' + + 'textarea{min-height:90px;resize:vertical}' + + '.btnrow{display:flex;justify-content:flex-end;gap:8px}' + + '.btnrow button{border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid #c9cfdd;background:#fff;color:#1a1a2e}' + + '.btnrow button.primary{background:#4a5cf0;border-color:#4a5cf0;color:#fff}' + + '#overlaybg{position:fixed;inset:0;z-index:2147483002;background:rgba(10,12,30,.35);display:none}' + + '#overlaybg.open{display:block}' + + '#toast{position:fixed;bottom:80px;right:24px;z-index:2147483004;background:#1a1a2e;color:#fff;padding:8px 16px;border-radius:8px;' + + 'font-size:13px;display:none}'; + + var wrap = document.createElement('div'); + wrap.innerHTML = + '' + + '' + + '' + + '
' + + '
' + + '
Docs review — PR 185
' + + '
Reviewer:
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
Comment on exact wording
Select text on the page. Your selection will stay here while you write feedback.
' + + '
' + + '
Selected text
' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '

Add feedback

' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '

Who is reviewing?

' + + '

Your name is attached to each comment so feedback can be tracked in Jira.

' + + '' + + '
' + + '
' + + '
'; + shadow.appendChild(wrap); + document.body.appendChild(host); + + function $(id) { return shadow.getElementById(id); } + var pill = $('pill'), selBtn = $('selbtn'), panel = $('panel'), composer = $('composer'), + nameModal = $('nameModal'), overlaybg = $('overlaybg'), toastEl = $('toast'); + + CATEGORIES.forEach(function (c) { + var o = document.createElement('option'); o.value = c; o.textContent = c; $('fCategory').appendChild(o); + }); + SEVERITIES.forEach(function (s) { + var o = document.createElement('option'); o.value = s; o.textContent = s; $('fSeverity').appendChild(o); + }); + $('fCategory').value = 'Suggestion'; + $('fSeverity').value = 'Minor'; + + var toastTimer = null; + function toast(msg) { + toastEl.textContent = msg; + toastEl.style.display = 'block'; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function () { toastEl.style.display = 'none'; }, 1800); + } + + function renderBadge() { + var n = pageItems().filter(function (i) { return i.status !== 'resolved'; }).length; + var total = visibleItems().length; + $('pillCount').textContent = n + '/' + total; + } + function renderSaveStatus() { + var map = { + idle: '', saving: 'Saving…', + saved: 'Saved to disk ✓', + offline: reviewer ? 'Review server unreachable — stored in browser only' : 'Set your name to save feedback' + }; + $('saveStatus').textContent = map[saveStatus] || ''; + } + function renderSelectionDraft() { + var box = $('selectionTools'); + if (!box) return; + var ready = !!(selectionDraft && selectionDraft.quote); + box.classList.toggle('ready', ready); + $('selectionQuote').textContent = ready ? selectionDraft.quote : ''; + pill.classList.toggle('selection-ready', ready); + $('pillLabel').innerHTML = ready ? '💬 Selected text' : '💬 Review'; + } + function jiraLinkHtml(issue, isEpic) { + if (!issue || !issue.key || !issue.url) return ''; + var classes = 'jira-link' + (isEpic ? ' epic' : '') + (issue.status === 'BLOCKED' ? ' blocked' : ''); + return '' + esc(issue.key) + + (issue.status ? ' ' + esc(issue.status) + '' : '') + ''; + } + function renderPageContext() { + var box = $('jiraContext'); + var epics = pageContext && Array.isArray(pageContext.epics) ? pageContext.epics : []; + var issues = pageContext && Array.isArray(pageContext.issues) ? pageContext.issues : []; + var blockers = pageContext && Array.isArray(pageContext.blockers) ? pageContext.blockers : []; + var count = $('jiraCount'); + count.hidden = blockers.length === 0; + count.textContent = blockers.length ? '\u26A0 ' + blockers.length : ''; + if (!epics.length && !issues.length && !blockers.length) { + box.hidden = true; + box.innerHTML = ''; + return; + } + var html = '
Jira context for this pagereview inputs · traceability
'; + html += ''; + if (blockers.length) { + html += '
' + blockers.length + + ' unresolved reviewer input' + (blockers.length === 1 ? '' : 's') + '
    '; + blockers.forEach(function (blocker) { + html += '
  • ' + esc(blocker.question || 'Review input needed.'); + if (blocker.issue && blocker.issue.url) { + html += ' ' + + esc(blocker.issue.key) + ''; + } + if (blocker.owner) html += 'Owner: ' + esc(blocker.owner) + ''; + html += '
  • '; + }); + html += '
'; + } else { + html += '
No page-specific blocker is recorded; use the linked Jira source for scope.
'; + } + box.innerHTML = html; + box.hidden = false; + } + function loadPageContext() { + var requestedPath = location.pathname; + var requestId = ++contextRequest; + fetch(API + '/context?path=' + encodeURIComponent(requestedPath)) + .then(function (r) { if (!r.ok) throw new Error('context request failed'); return r.json(); }) + .then(function (data) { + if (requestId !== contextRequest || requestedPath !== location.pathname) return; + pageContext = data || { epics: [], issues: [], blockers: [] }; + renderPageContext(); + }) + .catch(function () { + if (requestId !== contextRequest) return; + pageContext = { epics: [], issues: [], blockers: [] }; + renderPageContext(); + }); + } + function cardHtml(it) { + var eid = esc(it.id); // ids can arrive from shared feedback files — never trust them in markup + var found = !!anchoredRanges[it.id]; + var sev = '' + esc(it.severity) + ''; + var cat = '' + esc(it.category) + ''; + var orphan = (it.type === 'inline' && !found && it.page === location.pathname) + ? 'not found' : ''; + var quote = it.quote ? '
' + esc(it.quote) + '
' : ''; + var headline = it.heading ? '' : ''; + var goBtn = (it.page === location.pathname && found) ? '' : ''; + var openBtn = (it.page !== location.pathname) ? '' : ''; + return '
' + + '
' + sev + cat + orphan + '
' + + quote + headline + + '
' + esc(it.comment) + '
' + + '' + + '
' + goBtn + openBtn + + '' + + '' + + '' + + '
'; + } + function renderList() { + var listEl = $('list'); + var data = showAllPages ? visibleItems() : pageItems(); + if (!data.length) { + listEl.innerHTML = '
No feedback ' + (showAllPages ? 'yet' : 'on this page yet') + + '.

Select any text on the page and click
💬 Comment on selection,
or add a page-level note above.
'; + return; + } + var html = ''; + if (showAllPages) { + var byPage = {}; + data.forEach(function (it) { (byPage[it.page] = byPage[it.page] || []).push(it); }); + Object.keys(byPage).sort().forEach(function (pg) { + html += '
' + esc(pg) + '
'; + byPage[pg].forEach(function (it) { html += cardHtml(it); }); + }); + } else { + data.forEach(function (it) { html += cardHtml(it); }); + } + listEl.innerHTML = html; + } + function renderWho() { $('who').textContent = reviewer || '—'; } + function renderAll() { renderBadge(); renderList(); renderWho(); renderSaveStatus(); renderSelectionDraft(); renderPageContext(); } + + function openPanel() { panel.classList.add('open'); hideSelBtn(); renderAll(); } + function closePanel() { panel.classList.remove('open'); } + function openComposer(title, quote) { + if (!reviewer) { pendingAfterName = function () { openComposer(title, quote); }; openNameModal(); return; } + composerCtx = { page: location.pathname, pageTitle: pageTitle() }; + $('composerTitle').textContent = title; + var q = $('composerQuote'); + if (quote) { q.textContent = quote; q.style.display = 'block'; } else { q.style.display = 'none'; } + composer.classList.add('open'); + overlaybg.classList.add('open'); + $('fComment').focus(); + } + function closeComposer() { + composer.classList.remove('open'); + overlaybg.classList.remove('open'); + $('fComment').value = ''; + pending = null; + editingId = null; + } + var pendingAfterName = null; + function openNameModal() { + nameModal.classList.add('open'); + overlaybg.classList.add('open'); + $('fName').value = reviewer; + $('fName').focus(); + } + function closeNameModal() { + nameModal.classList.remove('open'); + if (!composer.classList.contains('open')) overlaybg.classList.remove('open'); + } + + // ---------------- events ---------------- + pill.addEventListener('click', function () { + if (panel.classList.contains('open')) closePanel(); else openPanel(); + }); + $('closePanel').addEventListener('click', closePanel); + $('editWho').addEventListener('click', openNameModal); + $('saveJson').addEventListener('click', saveJsonBackup); + $('importJson').addEventListener('click', function () { $('importJsonFile').click(); }); + $('importJsonFile').addEventListener('change', function (e) { + var file = e.target.files && e.target.files[0]; + importJsonBackup(file); + e.target.value = ''; + }); + $('allPages').addEventListener('change', function (e) { showAllPages = e.target.checked; renderList(); }); + $('addPageNote').addEventListener('click', function () { + pending = null; editingId = null; + openComposer('Page note — ' + location.pathname, ''); + }); + function beginSelectionComment() { + if (!selectionDraft || !selectionDraft.quote) return; + pending = selectionDraft; + hideSelBtn(); + var sel = document.getSelection(); + if (sel) sel.removeAllRanges(); + editingId = null; + openComposer('Comment on selection', pending ? pending.quote : ''); + } + selBtn.addEventListener('click', beginSelectionComment); + $('commentSelection').addEventListener('click', beginSelectionComment); + $('clearSelection').addEventListener('click', function () { + var sel = document.getSelection(); + if (sel) sel.removeAllRanges(); + clearSelectionDraft(); + }); + $('composerCancel').addEventListener('click', closeComposer); + $('composerSave').addEventListener('click', function () { + var comment = $('fComment').value.trim(); + if (!comment) { $('fComment').focus(); return; } + saveItem({ category: $('fCategory').value, severity: $('fSeverity').value, comment: comment }); + closeComposer(); + }); + $('nameSave').addEventListener('click', function () { + var v = $('fName').value.trim(); + if (!v) { $('fName').focus(); return; } + reviewer = v; + try { localStorage.setItem(LS_REVIEWER, reviewer); } catch (e) {} + closeNameModal(); + renderWho(); + mergeServerState(); + if (pendingAfterName) { var f = pendingAfterName; pendingAfterName = null; f(); } + }); + $('fName').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('nameSave').click(); }); + overlaybg.addEventListener('click', function () { closeComposer(); closeNameModal(); }); + $('list').addEventListener('click', function (e) { + var btn = e.target.closest('button[data-act]'); + if (!btn) return; + var id = btn.getAttribute('data-id'); + var act = btn.getAttribute('data-act'); + var it = items.filter(function (x) { return x.id === id; })[0]; + if (!it) return; + if (act === 'delete') { if (confirm('Delete this feedback item?')) deleteItem(id); } + else if (act === 'resolve') toggleResolve(id); + else if (act === 'open') { + // only same-origin paths — pages can arrive from shared feedback files + if (/^\/([^\/]|$)/.test(String(it.page || ''))) location.href = it.page; + } + else if (act === 'go') { + var r = anchoredRanges[id]; + if (r) { + var el = r.startContainer.nodeType === 1 ? r.startContainer : r.startContainer.parentElement; + if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + flashRange(r); + } + } + else if (act === 'edit') { + editingId = id; + pending = null; + $('fCategory').value = it.category || CATEGORIES[0]; + $('fSeverity').value = it.severity || 'Minor'; + openComposer('Edit feedback', it.quote || ''); + $('fComment').value = it.comment || ''; + } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { closeComposer(); closeNameModal(); hideSelBtn(); } + }); + document.addEventListener('pointerup', function (e) { + var path = e.composedPath ? e.composedPath() : []; + if (path.indexOf(host) !== -1) return; + setTimeout(onSelectionSettled, 30); + }); + document.addEventListener('keyup', function (e) { + if (e.shiftKey || e.key === 'Shift') setTimeout(onSelectionSettled, 30); + }); + document.addEventListener('selectionchange', function () { + if (selectionTimer) clearTimeout(selectionTimer); + selectionTimer = setTimeout(onSelectionSettled, 120); + }); + // click on a highlight opens the panel scrolled to that card + document.addEventListener('click', function (e) { + var path = e.composedPath ? e.composedPath() : []; + if (path.indexOf(host) !== -1) return; + var ids = Object.keys(anchoredRanges); + for (var i = 0; i < ids.length; i++) { + var rects = anchoredRanges[ids[i]].getClientRects(); + for (var j = 0; j < rects.length; j++) { + var rc = rects[j]; + if (e.clientX >= rc.left && e.clientX <= rc.right && e.clientY >= rc.top && e.clientY <= rc.bottom) { + openPanel(); + var card = shadow.querySelector('[data-card="' + CSS.escape(ids[i]) + '"]'); + if (card) { card.scrollIntoView({ block: 'center' }); card.style.outline = '2px solid #4a5cf0'; } + return; + } + } + } + }, true); + + // SPA navigation: re-anchor highlights when the route or DOM changes + function onNavigate() { + clearSelectionDraft(); + pageContext = { epics: [], issues: [], blockers: [] }; + renderPageContext(); + loadPageContext(); + scheduleAnchor(); + setTimeout(renderAll, 450); + } + var origPush = history.pushState; + history.pushState = function () { origPush.apply(this, arguments); onNavigate(); }; + var origReplace = history.replaceState; + history.replaceState = function () { origReplace.apply(this, arguments); onNavigate(); }; + window.addEventListener('popstate', onNavigate); + new MutationObserver(function (muts) { + for (var i = 0; i < muts.length; i++) { + if (muts[i].target === host || host.contains(muts[i].target)) continue; + scheduleAnchor(); + return; + } + }).observe(document.body, { childList: true, subtree: true }); + + // ---------------- boot ---------------- + renderAll(); + loadPageContext(); + scheduleAnchor(); + if (reviewer) { mergeServerState(); saveStatus = 'saved'; } + renderSaveStatus(); +})(); +`; + +// ------------------------------------------------------------------ proxy +const INJECT_TAG = ''; + +function injectOverlay(html) { + const lower = html.toLowerCase(); + let at = lower.lastIndexOf(''); + if (at === -1) at = lower.lastIndexOf(''); + if (at === -1) return html + INJECT_TAG; + return html.slice(0, at) + INJECT_TAG + html.slice(at); +} + +function readBody(req, limitBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > limitBytes) { reject(new Error('body too large')); req.destroy(); return; } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function sendJson(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(body); +} + +async function handleReviewRoute(req, res, url) { + const p = url.pathname; + if (p === '/__review__/' || p === '/__review__') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + res.end(statusPage()); + return; + } + if (p === '/__review__/overlay.js') { + res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' }); + res.end(OVERLAY_JS); + return; + } + if (p === '/__review__/api/context' && req.method === 'GET') { + sendJson(res, 200, reviewContextForPath(url.searchParams.get('path') || '/')); + return; + } + if (p === '/__review__/api/state' && req.method === 'GET') { + const reviewer = url.searchParams.get('reviewer') || ''; + sendJson(res, 200, { reviewer, items: readReviewerState(reviewer) }); + return; + } + if (p === '/__review__/api/state' && req.method === 'POST') { + try { + const body = JSON.parse((await readBody(req, 8 * 1024 * 1024)).toString('utf8')); + if (!body || typeof body.reviewer !== 'string' || !body.reviewer.trim() || !Array.isArray(body.items)) { + sendJson(res, 400, { error: 'expected { reviewer, items[] }' }); + return; + } + writeReviewerState(body.reviewer.trim(), body.items); + sendJson(res, 200, { ok: true, saved: body.items.length }); + } catch (e) { + sendJson(res, 400, { error: String(e.message || e) }); + } + return; + } + if (p === '/__review__/api/import' && req.method === 'POST') { + try { + const payload = JSON.parse((await readBody(req, 8 * 1024 * 1024)).toString('utf8')); + sendJson(res, 200, importFeedbackPayload(payload)); + } catch (e) { + sendJson(res, 400, { error: String(e.message || e) }); + } + return; + } + if (p.startsWith('/__review__/export/')) { + const exportPayload = feedbackExportPayload(); + const { items, reviewers } = exportPayload; + const kind = p.split('/').pop(); + if (kind === 'feedback.csv') { + res.writeHead(200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': 'attachment; filename="pr185-docs-feedback.csv"', + 'cache-control': 'no-store', + }); + res.end(toCsv(items)); + return; + } + if (kind === 'feedback.md') { + res.writeHead(200, { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': 'attachment; filename="pr185-docs-feedback.md"', + 'cache-control': 'no-store', + }); + res.end(toMarkdown(items, reviewers)); + return; + } + if (kind === 'feedback.json') { + res.writeHead(200, { + 'content-type': 'application/json', + 'content-disposition': 'attachment; filename="pr185-docs-feedback.json"', + 'cache-control': 'no-store', + }); + res.end(JSON.stringify(exportPayload, null, 2)); + return; + } + } + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); +} + +function badGateway(res, err) { + if (res.headersSent) { try { res.destroy(); } catch { /* already gone */ } return; } + res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' }); + res.end(` +

Docs preview is not running

+

The review server could not reach the Mintlify preview at ${esc(TARGET.origin)}.

+

In the repo folder, start it first:

+
npm run dev -- --no-open
+

then reload this page. If the preview started on a different port, restart this server with +node review-server.mjs --target http://localhost:<port>.

+

(${esc(err.message || err)})

`); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + if (url.pathname.startsWith('/__review__')) { + try { await handleReviewRoute(req, res, url); } catch (e) { sendJson(res, 500, { error: String(e.message || e) }); } + return; + } + + const headers = { ...req.headers, host: TARGET.host, 'accept-encoding': 'identity' }; + const upstream = http.request( + { hostname: TARGET.hostname, port: TARGET.port || 80, path: req.url, method: req.method, headers }, + (up) => { + const outHeaders = { ...up.headers }; + delete outHeaders['content-security-policy']; + delete outHeaders['content-security-policy-report-only']; + if (outHeaders.location && outHeaders.location.startsWith(TARGET.origin)) { + outHeaders.location = outHeaders.location.slice(TARGET.origin.length) || '/'; + } + const ctype = String(up.headers['content-type'] || ''); + if (ctype.includes('text/html') && req.method !== 'HEAD') { + const chunks = []; + up.on('data', (c) => chunks.push(c)); + up.on('end', () => { + const html = injectOverlay(Buffer.concat(chunks).toString('utf8')); + delete outHeaders['content-length']; + delete outHeaders['transfer-encoding']; + outHeaders['content-length'] = Buffer.byteLength(html); + res.writeHead(up.statusCode, outHeaders); + res.end(html); + }); + up.on('error', () => { try { res.destroy(); } catch {} }); + } else { + res.writeHead(up.statusCode, outHeaders); + up.pipe(res); + up.on('error', () => { try { res.destroy(); } catch { /* already gone */ } }); + } + } + ); + upstream.on('error', (err) => badGateway(res, err)); + req.pipe(upstream); +}); + +// websocket passthrough (Next.js HMR etc.) +server.on('upgrade', (req, socket, head) => { + const upstream = net.connect(Number(TARGET.port || 80), TARGET.hostname, () => { + let raw = `${req.method} ${req.url} HTTP/1.1\r\n`; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const k = req.rawHeaders[i]; + const v = k.toLowerCase() === 'host' ? TARGET.host : req.rawHeaders[i + 1]; + raw += `${k}: ${v}\r\n`; + } + raw += '\r\n'; + upstream.write(raw); + if (head && head.length) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); + socket.on('error', () => upstream.destroy()); +}); + +server.listen(PORT, () => { + console.log(''); + console.log(' Vast.ai docs review server (PR #185)'); + console.log(' ------------------------------------'); + console.log(` Review the docs at: http://localhost:${PORT}/host/hosting-overview`); + console.log(` Proxying preview at: ${TARGET.origin} (start it with: npm run dev -- --no-open)`); + console.log(` Feedback saved to: ${FEEDBACK_DIR}`); + console.log(` Status & exports: http://localhost:${PORT}/__review__/`); + console.log(''); +}); diff --git a/scripts/check_persona_chips.py b/scripts/check_persona_chips.py new file mode 100644 index 00000000..0c23b48a --- /dev/null +++ b/scripts/check_persona_chips.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Check that persona frontmatter and visible persona chips stay in sync. + +Every authored host page (host/*.mdx) carries the persona taxonomy twice: +machine-readable `personas:` frontmatter and a hand-written chip div rendered +top-right of the page. This check fails when the two drift apart. + +Conventions enforced (matching the CON-1518 IA rulings): + - slug -> chip label: pro-operator "Pro Operator", headless-operator + "Headless / DC", business-owner "Business", hobbyist "Hobbyist" + - all four personas collapse to a single "All host personas" chip + - chip order within the div is free; membership must match exactly + - generated host/cli/* and host/sdk/* pages are exempt (untagged by design) + +Usage: python3 scripts/check_persona_chips.py (exit 0 = in sync) +""" + +import glob +import re +import sys + +SLUG_TO_LABEL = { + "pro-operator": "Pro Operator", + "headless-operator": "Headless / DC", + "business-owner": "Business", + "hobbyist": "Hobbyist", +} +ALL_PERSONAS_LABEL = "All host personas" + +FRONTMATTER_RE = re.compile(r"^personas:\n((?:[ \t]+- .+\n)+)", re.M) +SLUG_RE = re.compile(r"[ \t]+- (.+)") +CHIP_DIV_RE = re.compile(r'
(.*?)
', re.S) +CHIP_RE = re.compile(r'([^<]+)') + + +def check_file(path): + errors = [] + src = open(path, encoding="utf-8").read() + + fm = FRONTMATTER_RE.search(src) + slugs = [s.strip() for s in SLUG_RE.findall(fm.group(1))] if fm else [] + divs = CHIP_DIV_RE.findall(src) + + if not slugs: + errors.append("missing or empty `personas:` frontmatter") + if not divs: + errors.append("missing persona-chips div") + if len(divs) > 1: + errors.append(f"expected 1 persona-chips div, found {len(divs)}") + if not slugs or not divs: + return errors + + unknown = [s for s in slugs if s not in SLUG_TO_LABEL] + if unknown: + errors.append(f"unknown persona slug(s) in frontmatter: {unknown}") + return errors + if len(set(slugs)) != len(slugs): + errors.append(f"duplicate persona slug(s) in frontmatter: {slugs}") + + chips = CHIP_RE.findall(divs[0]) + if set(slugs) == set(SLUG_TO_LABEL): + expected = [ALL_PERSONAS_LABEL] + else: + expected = [SLUG_TO_LABEL[s] for s in slugs] + + if sorted(chips) != sorted(expected): + errors.append( + f"chips {chips} do not match personas frontmatter " + f"(expected {sorted(expected)} in any order)" + ) + return errors + + +def main(): + pages = sorted(glob.glob("host/*.mdx")) + if not pages: + print("check_persona_chips: no host/*.mdx pages found — run from the repo root") + return 1 + + failed = 0 + for path in pages: + for err in check_file(path): + print(f"FAIL {path}: {err}") + failed += 1 + if failed: + print(f"\ncheck_persona_chips: {failed} problem(s) across {len(pages)} pages") + return 1 + print(f"check_persona_chips: OK — {len(pages)} pages, frontmatter and chips in sync") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_self_test_reference.py b/scripts/generate_self_test_reference.py new file mode 100644 index 00000000..d0efb7e0 --- /dev/null +++ b/scripts/generate_self_test_reference.py @@ -0,0 +1,772 @@ +#!/usr/bin/env python3 +"""Generate the host self-test reference page from self-test source code. + +The generator intentionally reads the current Vast CLI diagnostics and +self-test image metadata instead of duplicating threshold and remediation copy +by hand in docs. Pass explicit ``--vast-cli`` and ``--self-test`` paths when +generating from source branches that are not checked out next to this docs repo. +""" + +from __future__ import annotations + +import argparse +import ast +import html +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +DOCS_ROOT = Path(__file__).resolve().parents[1] +WORKSPACE_ROOT = DOCS_ROOT.parent + + +def existing_default(*candidates: Path) -> Path: + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[-1] + + +DEFAULT_VAST_CLI = existing_default( + WORKSPACE_ROOT / "vast-cli", + WORKSPACE_ROOT / "vast-cli-con1510-p1", +) +DEFAULT_SELF_TEST = WORKSPACE_ROOT / "self-test" +DEFAULT_OUTPUT = DOCS_ROOT / "host" / "self-test-reference.mdx" + + +def run_git(repo: Path, *args: str) -> str | None: + try: + return subprocess.check_output( + ["git", "-C", str(repo), *args], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + +def repo_ref(repo: Path) -> dict[str, str]: + remote = ( + run_git(repo, "remote", "get-url", "upstream") + or run_git(repo, "remote", "get-url", "origin") + or repo.name + ) + if "vast-ai/vast-cli" in remote or "jjziets/vast-python" in remote: + label = "vast-ai/vast-cli" + elif "vast-ai/self-test" in remote or "jjziets/self-test" in remote: + label = "vast-ai/self-test" + else: + label = repo.name + branch = run_git(repo, "branch", "--show-current") or "" + if not branch: + containing_refs = run_git(repo, "branch", "-r", "--contains", "HEAD", "--format=%(refname:short)") or "" + refs = [ref.strip() for ref in containing_refs.splitlines() if ref.strip()] + preferred_refs = ("upstream/master", "origin/master", "upstream/main", "origin/main") + branch = next((ref for ref in preferred_refs if ref in refs), refs[0] if refs else "unknown") + branch = re.sub(r"^(?:origin|upstream)/", "", branch) + commit = run_git(repo, "rev-parse", "--short", "HEAD") or "unknown" + status = run_git(repo, "status", "--porcelain") or "" + return { + "label": label, + "branch": branch, + "commit": commit, + "dirty": "yes" if status else "no", + } + + +def literal_assignment(path: Path, name: str) -> Any: + module = ast.parse(path.read_text(), filename=str(path)) + for node in module.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == name: + return ast.literal_eval(node.value) + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == name: + return ast.literal_eval(node.value) + raise ValueError(f"{name} not found in {path}") + + +def first_regex(text: str, pattern: str, default: str) -> str: + match = re.search(pattern, text) + return match.group(1) if match else default + + +def parse_cli_image_config(vast_cli: Path) -> dict[str, Any]: + machines_py = vast_cli / "vastai" / "cli" / "commands" / "machines.py" + text = machines_py.read_text() + repo = first_regex(text, r'docker_repo\s*=\s*"([^"]+)"', "vastai/test") + prefix = first_regex(text, r'image_tag_prefix\s*=\s*"([^"]+)"', "self-test-v2-cuda") + versions = [] + for left, right in re.findall(r'"(\d+\.\d+)"\s*:\s*image_for\("(\d+\.\d+)"\)', text): + if left == right: + versions.append(left) + return { + "repo": repo, + "prefix": prefix, + "versions": versions or ["11.8", "12.8", "13.0", "13.3"], + } + + +def parse_self_test_image_catalog(self_test: Path) -> dict[str, dict[str, str]]: + readme = self_test / "README.md" + catalog: dict[str, dict[str, str]] = {} + pattern = re.compile( + r"^\| `vastai/test:self-test-cuda-(\d+\.\d+)` \| ([^|]+) \| ([^|]+) \| ([^|]+) \|$" + ) + for line in readme.read_text().splitlines(): + match = pattern.match(line.strip()) + if not match: + continue + version, torch_version, targets, platforms = match.groups() + catalog[version] = { + "torch": torch_version.strip(), + "targets": targets.strip(), + "platforms": platforms.strip(), + } + return catalog + + +def cell(value: Any) -> str: + text = "" if value is None else str(value) + text = normalize_text(text) + text = html.escape(text, quote=False) + text = text.replace("\n", "
") + text = text.replace("|", "\\|") + return text + + +def normalize_text(text: str) -> str: + text = text.replace("1 listed GPU(s)", "the listed GPU count") + text = text.replace("Machine ID", "machine") + text = text.replace("machine ID", "machine") + text = text.replace("machine_id", "machine") + text = text.replace( + "vastai search offers 'machine=example rentable=any rented=any'", + "review the machine's listing and offer state in the Console", + ) + text = text.replace( + "vastai search offers 'machine= rentable=any rented=any'", + "review the machine's listing and offer state in the Console", + ) + return text.strip() + + +def bullet_lines(items: list[str]) -> list[str]: + return [f"- {item}" for item in items] + + +def code(value: str) -> str: + return f"`{value}`" + + +def preflight_threshold(check: dict[str, Any], system_ram_cap_mib: int) -> str: + check_id = check["id"] + if check_id == "cuda.version": + return "CUDA version >= 11.8" + if check_id == "reliability": + return "Reliability > 0.90" + if check_id == "network.direct_ports": + return "Direct ports >= 3 * listed GPUs" + if check_id == "pcie.bandwidth": + return "PCIe bandwidth > 2.85 GB/s" + if check_id == "network.download": + return "Download >= min(500, max(100, 500 * total_vram_gib / 192)) Mb/s" + if check_id == "network.upload": + return "Upload >= min(500, max(100, 500 * total_vram_gib / 192)) Mb/s" + if check_id == "gpu.ram": + return "Per-GPU VRAM > 7 GiB" + if check_id == "system.ram": + return f"System RAM >= min(0.95 * total GPU VRAM, {system_ram_cap_mib:,} MiB)" + if check_id == "cpu.cores": + return "Physical CPU cores >= listed GPUs" + if check_id == "network.direct_ports.recommended_max": + return "direct ports <= 64 * listed GPUs" + return f"{check.get('operator', '')} {check.get('required', '')} {check.get('unit', '')}".strip() + + +def preflight_purpose(check: dict[str, Any]) -> str: + if check["id"] == "cpu.cores": + return ( + "The tester expects at least one physical CPU core per listed GPU. " + "Hyperthreads/logical CPUs do not count as physical cores." + ) + return check["purpose"] + + +def preflight_remediation(check: dict[str, Any]) -> str: + if check["id"] == "cuda.version": + return "Update the NVIDIA driver/CUDA stack, then confirm the machine is listed and healthy in the Console." + if check["id"] == "cpu.cores": + return "Add physical CPU cores or reduce the listed GPU count for this offer." + return check["remediation"] + + +def runtime_thresholds(system_ram_cap_mib: int) -> dict[str, str]: + return { + "image_started": "The runtime container starts and writes the first progress event.", + "system_requirements": ( + "Each GPU has at least 98% free VRAM; system RAM is at least " + f"95% of total GPU VRAM capped at {system_ram_cap_mib:,} MiB; " + "there is at least 1 visible physical CPU core per visible GPU. " + "Hyperthreads/logical CPUs do not count as physical cores." + ), + "resnet": "A CUDA ResNet18 workload completes on the visible GPU set at any tested batch size.", + "ecc": "The test allocates 95% of total memory on each visible GPU.", + "nccl": "At least 1 GPU is visible and all NCCL ranks initialize and synchronize on one machine.", + "stress_gpu_burn": "stress-ng and gpu-burn run together for 60 seconds and both exit with code 0.", + "final_summary": "The runtime reports the overall pass/fail result and exit code.", + } + + +NO_OFFER_ROOT_STATE_COPY = { + "currently_rented": "Visible offers exist and one or more are already rented.", + "deverified_or_below_threshold": "Visible offers exist but host reliability, verification state, vericode, or error metadata points to a host quality gate.", + "api_permission_failed": "The API key or account could not read the machine or offer state required by self-test.", + "zero_active_offers": "The machine is visible, but no active on-demand offers are listed for it.", + "offline_or_not_listed": "The machine is not visible to the account or appears offline/not listed.", + "unknown_no_rentable_offer": "Visible offers exist, but the payload does not expose a specific non-rentable reason.", +} + + +PREFLIGHT_FAILURE_CODES = [ + ( + "no_offer", + "No on-demand offer found for the machine.", + "Confirm the host is online, listed, and has a visible on-demand offer in the Console.", + ), + ( + "no_rentable_offer", + "Offers were visible, but none were currently rentable.", + "Wait for rentals/state refreshes or inspect host offer state.", + ), + ( + "api_permission_failed", + "The API key could not inspect the required machine/offer state.", + "Use an API key/account with host machine and offer visibility.", + ), + ( + "preflight_requirements_failed", + "One or more minimum requirement checks failed before renting.", + "Resolve the failed checks or rerun with --ignore-requirements for runtime diagnostics only.", + ), +] + + +def failure_area(code_value: str) -> str: + if code_value.startswith("instance_") or code_value in { + "missing_public_ip", + "progress_port_not_mapped", + "progress_endpoint_unreachable", + "progress_endpoint_lost", + "progress_empty_timeout", + "runtime_test_timeout", + "interrupted", + "cleanup_failed", + }: + return "Launch, network, or cleanup" + if code_value in {"docker_pull_failed", "daemon_startup_failed"}: + return "Image or container startup" + if code_value in { + "nvml_failed", + "resnet_failed", + "ecc_failed", + "nccl_failed", + "stress_gpu_burn_failed", + "legacy_progress_error", + }: + return "Runtime test" + return "Runtime" + + +def load_cli_metadata(vast_cli: Path) -> dict[str, Any]: + sys.path.insert(0, str(vast_cli)) + try: + from vastai.cli.self_test.machine_diagnostics import ( # type: ignore + NO_OFFER_ROOT_STATES, + SYSTEM_RAM_REQUIREMENT_CAP_MIB, + preflight_requirement_checks, + ) + from vastai.cli.self_test.runtime_diagnostics import failure_catalog # type: ignore + from vastai.cli.self_test.support_bundle import ( # type: ignore + DEFAULT_BUNDLE_DIR, + MAX_LOG_BYTES, + MAX_TEXT_BYTES, + ) + from vastai.cli.util import required_inet_mbps # type: ignore + finally: + try: + sys.path.remove(str(vast_cli)) + except ValueError: + pass + + sample_offer = { + "id": 1, + "machine_id": "example", + "gpu_name": "RTX_4090", + "num_gpus": 1, + "dph_total": 0.1, + "dlperf": 100, + "cuda_max_good": 13.3, + "compute_cap": 890, + "reliability": 0.99, + "direct_port_count": 100, + "pcie_bw": 16.0, + "inet_down": 500, + "inet_up": 500, + "gpu_ram": 24 * 1024, + "gpu_total_ram": 24 * 1024, + "cpu_ram": 64 * 1024, + "cpu_cores": 8, + "rentable": True, + "rented": False, + "verification": "verified", + } + + return { + "preflight_checks": preflight_requirement_checks(sample_offer), + "failure_catalog": failure_catalog(), + "no_offer_root_states": list(NO_OFFER_ROOT_STATES), + "system_ram_cap_mib": SYSTEM_RAM_REQUIREMENT_CAP_MIB, + "support_bundle": { + "default_bundle_dir": DEFAULT_BUNDLE_DIR, + "max_text_bytes": MAX_TEXT_BYTES, + "max_log_bytes": MAX_LOG_BYTES, + }, + "bandwidth_examples": [ + ("8 GiB total VRAM", required_inet_mbps(8 * 1024)), + ("48 GiB total VRAM", required_inet_mbps(48 * 1024)), + ("80 GiB total VRAM", required_inet_mbps(80 * 1024)), + ("96 GiB total VRAM", required_inet_mbps(96 * 1024)), + ("160 GiB total VRAM", required_inet_mbps(160 * 1024)), + ("192 GiB total VRAM or more", required_inet_mbps(192 * 1024)), + ], + } + + +def render_preflight_table(checks: list[dict[str, Any]], system_ram_cap_mib: int) -> str: + lines = [ + "| Check | Gate | Purpose | Host guidance |", + "| --- | --- | --- | --- |", + ] + for check in checks: + status = "Advisory" if check.get("status") == "info" else "Required" + gate = f"{status}: {preflight_threshold(check, system_ram_cap_mib)}" + lines.append( + "| " + + " | ".join( + [ + f"{code(check['id'])}
{cell(check['title'])}", + cell(gate), + cell(preflight_purpose(check)), + cell(preflight_remediation(check)), + ] + ) + + " |" + ) + return "\n".join(lines) + + +def render_bandwidth_examples(examples: list[tuple[str, float]]) -> str: + lines = ["| Total machine VRAM | Required upload and download |", "| --- | --- |"] + for label, mbps in examples: + value = f"{mbps:.2f}".rstrip("0").rstrip(".") + lines.append(f"| {cell(label)} | {cell(value + ' Mb/s')} |") + return "\n".join(lines) + + +def render_runtime_stage_table(event_catalog: dict[str, dict[str, Any]], thresholds: dict[str, str]) -> str: + lines = [ + "| Stage | Pass condition / threshold | Purpose | Failure guidance |", + "| --- | --- | --- | --- |", + ] + for stage, entry in event_catalog.items(): + remediation = " ".join(entry.get("remediation") or []) + lines.append( + "| " + + " | ".join( + [ + f"{code(stage)}
{cell(entry['title'])}", + cell(thresholds.get(stage, "")), + cell(entry["purpose"]), + cell(remediation), + ] + ) + + " |" + ) + return "\n".join(lines) + + +def render_image_table(image_config: dict[str, Any], image_catalog: dict[str, dict[str, str]]) -> str: + lines = [ + "| CLI image | Torch | Targets | Platforms |", + "| --- | --- | --- | --- |", + ] + for version in image_config["versions"]: + details = image_catalog.get(version, {}) + image = f"{image_config['repo']}:{image_config['prefix']}-{version}" + lines.append( + "| " + + " | ".join( + [ + code(image), + cell(details.get("torch", "")), + cell(details.get("targets", "")), + cell(details.get("platforms", "")), + ] + ) + + " |" + ) + return "\n".join(lines) + + +def render_preflight_failure_table(root_states: list[str]) -> str: + displayed_codes = {row[0] for row in PREFLIGHT_FAILURE_CODES} + lines = [ + "| Code or root state | Meaning | Guidance |", + "| --- | --- | --- |", + ] + for code_value, summary, guidance in PREFLIGHT_FAILURE_CODES: + lines.append(f"| {code(code_value)} | {cell(summary)} | {cell(guidance)} |") + for root_state in root_states: + if root_state in displayed_codes: + continue + lines.append( + f"| {code(root_state)} | {cell(NO_OFFER_ROOT_STATE_COPY.get(root_state, 'Root state reported by offer diagnostics.'))} | Review the machine page, listing state, active rentals, and offer visibility in the Console. |" + ) + return "\n".join(lines) + + +def render_runtime_failure_table(catalog: dict[str, dict[str, Any]]) -> str: + lines = [ + "| Code | Area | Meaning | Remediation | Suggested steps |", + "| --- | --- | --- | --- | --- |", + ] + for code_value, entry in catalog.items(): + suggested = "
".join(cell(step) for step in entry.get("suggested_steps") or []) + lines.append( + "| " + + " | ".join( + [ + code(code_value), + cell(failure_area(code_value)), + cell(entry["summary"]), + cell(entry["remediation"]), + suggested, + ] + ) + + " |" + ) + return "\n".join(lines) + + +def render_result_interpretation() -> str: + return "\n".join( + [ + "## How To Read The Result", + "", + "Self-test output has two distinct parts: preflight qualification checks and the runtime workload.", + "", + "| Result | What it means | What to do next |", + "| --- | --- | --- |", + "| Normal pass | Minimum requirements passed and the runtime workload passed. The machine is eligible for verification, subject to the normal platform verification process. | Keep the host stable and listed. Verification is still automated and not guaranteed immediately. |", + "| Normal preflight failure | The CLI found one or more requirement failures before renting a temporary instance. | Fix the measured values shown in the CLI, then rerun without `--ignore-requirements`. |", + "| Runtime failure | The CLI rented a temporary instance, started the self-test image, and a runtime stage failed or timed out. | Use the failure code, last runtime stage, and diagnostic bundle to identify the failing subsystem. |", + "| Pass with `--ignore-requirements` | The runtime workload passed, but minimum requirement checks were skipped. This does not qualify the machine for verification. | Treat this as runtime validation only. Rerun without `--ignore-requirements` to see qualification status. |", + "", + "", + "If you use `--ignore-requirements`, still review any requirement diagnostics from a normal run. A runtime pass proves the container workload can run; it does not prove the machine meets the verification gate.", + "", + ] + ) + + +def render_ports_guidance() -> str: + return "\n".join( + [ + "### Direct Ports And Port Mapping", + "", + "The self-test needs direct public connectivity to the temporary instance. The progress service runs inside the self-test container on `5000/tcp`, but the CLI connects to the mapped external public IP and external port reported by the instance.", + "", + "- Minimum gate: at least 3 direct ports per listed GPU.", + "- Useful cap: each instance can use up to 64 ports. Mapping more than 64 ports per listed GPU is usually unnecessary and is not a self-test requirement.", + "- Port forwarding should target the host's LAN address, not its public address.", + "- Keep TCP and UDP forwarding symmetric where your network setup requires both protocols.", + "- If the CLI reports a tested external IP:port, troubleshoot that external mapping first.", + "- If the host and CLI are on the same LAN, a local failure to reach the public IP can be NAT hairpinning. Retest from an outside network before assuming the port is closed globally.", + "", + "", + "The CLI can report the external progress port it tested when that mapping is available. A full list of exactly which direct ports failed still requires backend or daemon-side exposure.", + "", + ] + ) + + +def render_no_response_guidance() -> str: + return "\n".join( + [ + '', + "### No Response Or Progress Timeout", + "", + "A `no response` or progress timeout means the CLI could not get usable progress from the temporary self-test instance after it was created. This is usually a connectivity or startup problem, not a generic verification decision.", + "", + "Common causes:", + "", + "- Router or firewall forwards the external port to the wrong LAN IP.", + "- The external TCP port is closed, blocked, or not hairpin-accessible from the CLI's network.", + "- The self-test container never started, crashed, or did not bind the progress service.", + "- Docker, NVIDIA runtime, or the host daemon stalled during startup.", + "- The GPU or system hung under load before progress could be reported.", + "- Upload/network instability prevented progress responses from reaching the CLI.", + "", + "First checks:", + "", + "- Look at the failure code and the tested external IP:port in CLI output when present.", + "- Confirm the router/firewall forwards that external port to the host machine.", + "- Inspect the diagnostic bundle for `instance/container.log`, `instance/daemon.log`, and `instance/show-instance.json` when the instance existed.", + "- If you ran the CLI from the same LAN as the host, retry from a different network to rule out NAT loopback/hairpinning.", + ] + ) + + +def render_not_rentable_guidance() -> str: + return "\n".join( + [ + '', + "### Not Found Or Not Rentable", + "", + "The old `not found or not rentable` wording hid several different states. The newer CLI tries to disambiguate the state before giving guidance.", + "", + "Typical root causes:", + "", + "- The machine is currently rented.", + "- The machine is visible but has zero active on-demand offers.", + "- The machine is offline, unlisted, or not visible to the API account.", + "- The machine is deverified, below the reliability threshold, or has offer-side error metadata.", + "- The API key can authenticate but does not have permission to inspect the required host or offer state.", + "", + "Start with the machine page in the Console. Check whether the host is online, listed, already rented, below the reliability gate, missing an active on-demand offer, or being viewed from an account that cannot see the host state.", + ] + ) + + +def render_vericode_guidance() -> str: + return "\n".join( + [ + '', + "## vericode=8 And Port Networking Issues", + "", + "[Glossary: vericode](/host/glossary#vericode)", + "", + "`vericode=8` means the machine has a host-facing `error_msg` in platform state. If the visible message is `Port Networking Issues`, `Port issue`, or similar wording, treat it as a direct-port or connectivity problem. Other `vericode=8` cases can point to Docker/NVIDIA runtime, CDI, PCIe, daemon, or other machine errors, so use the exact Console message when choosing the next fix.", + "", + "For port-networking cases, check closed external ports, forwarding to the wrong LAN IP, CGNAT or double NAT, missing inbound public IP, host firewall rules, TCP/UDP mismatch, asymmetric protocol handling, and stale port ranges. See [Network & Ports](/host/network-ports) and [Machine Error Reference](/host/machine-errors).", + ] + ) + + +def render_nccl_guidance() -> str: + return "\n".join( + [ + '', + "### `nccl_failed` Deep Dive", + "", + "`nccl_failed` means the temporary self-test instance could not initialize and synchronize NCCL workers across the visible GPUs. It is a multi-GPU communication failure, not proof of one specific root cause.", + "", + "Start with the support bundle, confirm every GPU is visible to `nvidia-smi` and Docker, then check driver/CUDA/PyTorch/NCCL compatibility, peer-to-peer communication, PCIe/NVLink topology, Xid errors, GPU resets, and bus errors.", + "", + "On HGX, DGX, or other NVSwitch systems, also check Fabric Manager or NVLSM on the host. The self-test container can show NCCL symptoms, but it cannot confirm whether host services such as `nvidia-fabricmanager` are healthy.", + "", + "```bash", + "systemctl status nvidia-fabricmanager", + 'journalctl -u nvidia-fabricmanager --since "-24h"', + "nvidia-smi -q | grep -i -A 2 Fabric", + "nvidia-smi topo -m", + "```", + ] + ) + + +def render_bundle_boundary(support: dict[str, Any]) -> str: + return "\n".join( + [ + "## Diagnostic Bundles", + "", + "When a self-test fails, the CLI builds a redacted diagnostic tarball unless bundle creation is disabled.", + "", + "- Default output directory: `" + support["default_bundle_dir"] + "`.", + "- Disable automatic bundles with `--no-support-bundle` or `VAST_SELF_TEST_SUPPORT_BUNDLE=0`.", + "- Choose another directory with `--support-bundle-dir `.", + "- Create a manual CLI-visible bundle with `vastai dump-logs `.", + "- Include local host OS/kaalia artifacts only when running on the actual host with `vastai dump-logs --include-local-host-artifacts`.", + "", + "Default self-test bundles include `self-test-output.log`, `self-test-result.json`, `manifest.json`, and `collection-errors.json`. Runtime failures with a created instance can also include `instance/show-instance.json`, `instance/container.log`, and `instance/daemon.log` from the Vast instance logs API.", + "", + "", + "When the CLI is run from a laptop or other third-party machine, it cannot collect host-local files such as `/var/lib/vastai_kaalia/kaalia.log*`, `dmesg`, `journalctl`, `/etc/docker/daemon.json`, or `/proc/mounts` from the Vast host. Those artifacts require running the helper on the actual host or adding a future daemon/backend log-collection feature.", + "", + "", + f"Text artifacts are capped at {support['max_text_bytes']:,} bytes and log artifacts are capped at {support['max_log_bytes']:,} bytes. Obvious API keys, tokens, passwords, and related secrets are redacted, but hosts should still review the tarball before sharing it with support.", + ] + ) + + +def render_page(vast_cli: Path, self_test: Path) -> str: + cli = load_cli_metadata(vast_cli) + event_catalog = literal_assignment(self_test / "remote.py", "EVENT_CATALOG") + image_config = parse_cli_image_config(vast_cli) + image_catalog = parse_self_test_image_catalog(self_test) + system_ram_cap_mib = cli["system_ram_cap_mib"] + support = cli["support_bundle"] + + vast_cli_ref = repo_ref(vast_cli) + self_test_ref = repo_ref(self_test) + + lines = [ + "---", + 'title: "Verification / Self-test Reference"', + 'sidebarTitle: "Self-test Reference"', + 'description: "Generated reference for host self-test checks, thresholds, failure codes, and guidance."', + '"canonical": "/host/self-test-reference"', + "personas:", + " - pro-operator", + " - headless-operator", + "---", + "", + '
Pro OperatorHeadless / DC
', + "", + "{/*", + " This page is generated by scripts/generate_self_test_reference.py.", + " Do not edit this file by hand; update the Vast CLI/self-test source metadata, then regenerate.", + f" Source: {vast_cli_ref['label']} {vast_cli_ref['branch']}@{vast_cli_ref['commit']} dirty={vast_cli_ref['dirty']}.", + f" Source: {self_test_ref['label']} {self_test_ref['branch']}@{self_test_ref['commit']} dirty={self_test_ref['dirty']}.", + "*/}", + "", + "The host self-test is the quickest way to check whether a listed machine can pass Vast.ai's minimum verification gate and run the runtime workload used by the tester.", + "", + "When you run `vastai self-test machine `, the CLI selects a rentable offer for that machine, checks minimum requirements, rents one temporary diagnostic instance, starts the self-test image, polls the runtime progress endpoint, reports the result, and destroys the temporary instance.", + "", + "", + "Passing this self-test makes a machine eligible for verification, but it does not guarantee that the machine will be verified immediately. Verification also depends on ongoing health, reliability, supply and demand, and platform policy.", + "", + "", + "", + "`--ignore-requirements` is for advanced runtime diagnostics only. A pass with requirement checks ignored does not qualify the machine for verification.", + "", + "", + render_result_interpretation(), + "", + "## Preflight Checks", + "", + "These checks run before the CLI rents the temporary self-test instance. Failed required checks stop the normal flow before billing starts.", + "", + render_preflight_table(cli["preflight_checks"], system_ram_cap_mib), + "", + "", + f"For B300 and other very-high-VRAM hosts, the system RAM gate stops scaling at {system_ram_cap_mib:,} MiB (about 2 TB).", + "", + "", + render_ports_guidance(), + "", + "### Bandwidth Formula", + "", + "Upload and download thresholds scale with total machine VRAM:", + "", + "```text", + "required_mbps = min(500, max(100, 500 * total_vram_gib / 192))", + "```", + "", + render_bandwidth_examples(cli["bandwidth_examples"]), + "", + "## Self-test Image Selection", + "", + "The CLI selects from the self-test image family unless `--test-image` or `VAST_SELF_TEST_IMAGE` overrides the image for controlled testing.", + "", + render_image_table(image_config, image_catalog), + "", + "Selection rules:", + "", + "- Pre-Volta GPUs (`compute_cap < 700`) use the CUDA 11.8 image.", + "- Volta GPUs (`compute_cap < 750`) are capped at CUDA 12.8 because newer PyTorch CUDA 13 wheels do not include sm_70 support.", + "- Other hosts use the newest supported self-test image that is less than or equal to `cuda_max_good`.", + "", + "## Runtime Stages", + "", + "After preflight passes, the CLI starts the self-test image and polls the runtime progress service on container port `5000/tcp`.", + "", + render_runtime_stage_table(event_catalog, runtime_thresholds(system_ram_cap_mib)), + "", + "## Offer Selection And Preflight Issues", + "", + "The CLI reports stable preflight failure codes and, when possible, a likely root state for machines that are not currently rentable.", + "", + render_not_rentable_guidance(), + "", + render_preflight_failure_table(cli["no_offer_root_states"]), + "", + render_vericode_guidance(), + "", + "## Runtime Failure Codes", + "", + "Runtime failure codes are stable identifiers intended for CLI output, support workflows, and host-facing guidance.", + "", + render_no_response_guidance(), + "", + render_runtime_failure_table(cli["failure_catalog"]), + "", + render_nccl_guidance(), + "", + render_bundle_boundary(support), + "", + ] + + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--vast-cli", + default=os.environ.get("VAST_CLI_REPO", str(DEFAULT_VAST_CLI)), + type=Path, + help="Path to a vast-ai/vast-cli checkout or PR worktree.", + ) + parser.add_argument( + "--self-test", + default=os.environ.get("VAST_SELF_TEST_REPO", str(DEFAULT_SELF_TEST)), + type=Path, + help="Path to a vast-ai/self-test checkout.", + ) + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + type=Path, + help="MDX file to write.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + vast_cli = args.vast_cli.resolve() + self_test = args.self_test.resolve() + output = args.output.resolve() + + if not (vast_cli / "vastai" / "cli" / "self_test").exists(): + raise SystemExit(f"vast-cli self-test diagnostics not found: {vast_cli}") + if not (self_test / "remote.py").exists(): + raise SystemExit(f"self-test remote.py not found: {self_test}") + + page = render_page(vast_cli, self_test) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(page) + print(f"Wrote {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/review-context.test.mjs b/scripts/review-context.test.mjs new file mode 100644 index 00000000..eef5533d --- /dev/null +++ b/scripts/review-context.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +let targetServer; +let reviewProcess; +let targetOrigin; +let reviewOrigin; +let feedbackDir; +let reviewOutput = ''; + +function listen(server, port = 0) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(server.address().port); + }); + }); +} + +async function freePort() { + const server = http.createServer(); + const port = await listen(server); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function waitForReviewServer() { + let lastError; + for (let i = 0; i < 80; i += 1) { + if (reviewProcess.exitCode != null) { + throw new Error(`review server exited early (${reviewProcess.exitCode})\n${reviewOutput}`); + } + try { + const response = await fetch(`${reviewOrigin}/__review__/api/context?path=%2Fhost%2Fhost-teams`); + if (response.ok) return; + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`review server did not become ready: ${lastError}\n${reviewOutput}`); +} + +async function contextFor(pathname) { + const response = await fetch(`${reviewOrigin}/__review__/api/context?path=${encodeURIComponent(pathname)}`); + assert.equal(response.status, 200); + return response.json(); +} + +async function postJson(pathname, payload) { + return fetch(`${reviewOrigin}${pathname}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +async function reviewerState(reviewer) { + const response = await fetch(`${reviewOrigin}/__review__/api/state?reviewer=${encodeURIComponent(reviewer)}`); + assert.equal(response.status, 200); + return response.json(); +} + +before(async () => { + targetServer = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(`

Stub preview

${req.url}

`); + }); + const targetPort = await listen(targetServer); + const reviewPort = await freePort(); + targetOrigin = `http://127.0.0.1:${targetPort}`; + reviewOrigin = `http://127.0.0.1:${reviewPort}`; + feedbackDir = await fs.mkdtemp(path.join(os.tmpdir(), 'vast-review-context-')); + reviewProcess = spawn(process.execPath, [ + 'review-server.mjs', '--port', String(reviewPort), '--target', targetOrigin, '--dir', feedbackDir, + ], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }); + reviewProcess.stdout.on('data', (chunk) => { reviewOutput += chunk; }); + reviewProcess.stderr.on('data', (chunk) => { reviewOutput += chunk; }); + await waitForReviewServer(); +}); + +after(async () => { + if (reviewProcess && reviewProcess.exitCode == null) { + reviewProcess.kill('SIGTERM'); + await new Promise((resolve) => reviewProcess.once('exit', resolve)); + } + if (targetServer) await new Promise((resolve) => targetServer.close(resolve)); + if (feedbackDir) await fs.rm(feedbackDir, { recursive: true, force: true }); +}); + +test('Host Teams shows its Jira sources and only its page blockers', async () => { + const context = await contextFor('/host/host-teams'); + assert.deepEqual(context.epics.map((issue) => issue.key), ['CON-1187']); + assert.deepEqual(context.issues.map((issue) => issue.key), ['CON-1581', 'CON-1584']); + assert.equal(context.blockers.length, 4); + assert.ok(context.blockers.some((item) => item.question.includes('accrued earnings'))); + assert.ok(context.blockers.every((item) => item.issue.url.startsWith('https://vastai.atlassian.net/browse/'))); +}); + +test('Self-Test reference links both epics without stale implementation blockers', async () => { + const context = await contextFor('/host/self-test-reference'); + assert.deepEqual(context.epics.map((issue) => issue.key), ['CON-1187', 'CON-1509']); + for (const key of ['CON-1515', 'CON-1513', 'CON-1510', 'CON-1583', 'CON-1419']) { + assert.ok(context.issues.some((issue) => issue.key === key), `missing ${key}`); + } + assert.equal(context.blockers.length, 1); + assert.ok(context.blockers.some((item) => item.question.includes('queue and wait-time'))); + assert.ok(context.blockers.every((item) => !item.question.includes('actual-versus-required'))); + assert.ok(context.blockers.every((item) => !item.question.includes('source-repository dispatch'))); +}); + +test('Diagnostics no longer reports merged dump-logs documentation as missing', async () => { + const context = await contextFor('/host/common-errors-diagnostics'); + assert.ok(context.issues.some((issue) => issue.key === 'CON-1519')); + assert.ok(context.blockers.every((item) => !item.question.includes('vastai dump-logs'))); +}); + +test('Network page receives network blockers without unrelated Teams blockers', async () => { + const context = await contextFor('/host/network-ports'); + assert.deepEqual(context.issues.map((issue) => issue.key), ['CON-1517', 'CON-1514']); + assert.ok(context.blockers.some((item) => item.question.includes('TCP/UDP'))); + assert.ok(context.blockers.every((item) => item.issue.key === 'CON-1514')); +}); + +test('Unmapped Host pages retain epic provenance without invented blockers', async () => { + const context = await contextFor('/host/workload-policy'); + assert.equal(context.matched, false); + assert.deepEqual(context.epics.map((issue) => issue.key), ['CON-1187']); + assert.deepEqual(context.issues, []); + assert.deepEqual(context.blockers, []); +}); + +test('Non-Host pages do not inherit Host Jira context', async () => { + const context = await contextFor('/guides/get-started'); + assert.deepEqual(context.epics, []); + assert.deepEqual(context.issues, []); + assert.deepEqual(context.blockers, []); +}); + +test('Only the review proxy injects the overlay', async () => { + const targetHtml = await (await fetch(`${targetOrigin}/host/host-teams`)).text(); + const reviewHtml = await (await fetch(`${reviewOrigin}/host/host-teams`)).text(); + assert.doesNotMatch(targetHtml, /__review__\/overlay\.js/); + assert.match(reviewHtml, /__review__\/overlay\.js/); + const overlay = await (await fetch(`${reviewOrigin}/__review__/overlay.js`)).text(); + assert.match(overlay, /Jira context for this page/); + assert.match(overlay, /REVIEW-TRACEABILITY\.md/); + assert.match(overlay, /\/context\?path=/); + assert.match(overlay, /Save JSON/); + assert.match(overlay, /Import JSON/); + assert.match(overlay, /\/import/); +}); + +test('JSON import restores multiple reviewers and keeps newer server items', async () => { + const newerAlice = { + id: 'roundtrip-alice', reviewer: 'Alice', page: '/host/hosting-overview', + pageTitle: 'Hosting Overview', type: 'inline', quote: 'Hosts provide machines', + prefix: 'Overview: ', suffix: '; renters run workloads', heading: 'Hosting Overview', + category: 'Suggestion', severity: 'Minor', comment: 'Keep the newer wording.', + status: 'open', createdAt: '2026-07-13T08:00:00.000Z', updatedAt: '2026-07-13T10:00:00.000Z', + }; + const saveResponse = await postJson('/__review__/api/state', { reviewer: 'Alice', items: [newerAlice] }); + assert.equal(saveResponse.status, 200); + + const olderAlice = { ...newerAlice, comment: 'Older backup wording.', updatedAt: '2026-07-13T09:00:00.000Z' }; + const bob = { + id: 'roundtrip-bob', reviewer: 'Bob', page: '/host/network-ports', + pageTitle: 'Network & Ports', type: 'page', quote: '', prefix: '', suffix: '', heading: '', + category: 'Question', severity: 'Major', comment: 'Confirm the UDP wording.', + status: 'resolved', createdAt: '2026-07-13T09:15:00.000Z', updatedAt: '2026-07-13T09:20:00.000Z', + }; + const importResponse = await postJson('/__review__/api/import', { + format: 'vast-docs-review-feedback', version: 1, generatedAt: '2026-07-13T09:30:00.000Z', + pr: 'https://github.com/vast-ai/docs/pull/185', reviewers: ['Alice', 'Bob'], items: [olderAlice, bob], + }); + assert.equal(importResponse.status, 200); + const imported = await importResponse.json(); + assert.equal(imported.imported, 2); + assert.equal(imported.reviewerCount, 2); + + const aliceState = await reviewerState('Alice'); + assert.equal(aliceState.items.length, 1); + assert.equal(aliceState.items[0].comment, 'Keep the newer wording.'); + assert.equal(aliceState.items[0].quote, 'Hosts provide machines'); + assert.equal(aliceState.items[0].prefix, 'Overview: '); + assert.equal(aliceState.items[0].suffix, '; renters run workloads'); + + const bobState = await reviewerState('Bob'); + assert.equal(bobState.items.length, 1); + assert.equal(bobState.items[0].comment, 'Confirm the UDP wording.'); + + const exportResponse = await fetch(`${reviewOrigin}/__review__/export/feedback.json`); + assert.equal(exportResponse.status, 200); + const exported = await exportResponse.json(); + assert.equal(exported.format, 'vast-docs-review-feedback'); + assert.equal(exported.version, 1); + assert.ok(exported.items.some((item) => item.id === 'roundtrip-alice' && item.comment === 'Keep the newer wording.')); + assert.ok(exported.items.some((item) => item.id === 'roundtrip-bob')); + + const statusHtml = await (await fetch(`${reviewOrigin}/__review__/`)).text(); + assert.match(statusHtml, /Save JSON/); + assert.match(statusHtml, /Import JSON/); + assert.match(statusHtml, /restorable backup for every page and reviewer/); +}); + +test('JSON import rejects an invalid backup before writing any reviewer state', async () => { + const response = await postJson('/__review__/api/import', { + format: 'vast-docs-review-feedback', version: 1, + items: [ + { + id: 'atomic-valid', reviewer: 'Charlie', page: '/host/quickstart', comment: 'Would otherwise be valid.', + updatedAt: '2026-07-13T10:00:00.000Z', + }, + { id: 'atomic-invalid', page: '/host/quickstart', comment: 'Missing reviewer.' }, + ], + }); + assert.equal(response.status, 400); + const error = await response.json(); + assert.match(error.error, /missing a valid reviewer/); + const charlieState = await reviewerState('Charlie'); + assert.deepEqual(charlieState.items, []); +}); diff --git a/snippets/host/cli/self-test-machine.mdx b/snippets/host/cli/self-test-machine.mdx index 98259301..0eda832c 100644 --- a/snippets/host/cli/self-test-machine.mdx +++ b/snippets/host/cli/self-test-machine.mdx @@ -41,6 +41,8 @@ vastai self-test machine [--debugging] [--ignore-requirements] [--t This command tests if a machine meets specific requirements and runs a series of tests to ensure it's functioning correctly. +If the self-test fails, decode the exact message with the [Self-Test Reference](/host/self-test-reference). For machine-level errors surfaced outside the self-test, see the [Machine Error Reference](/host/machine-errors). + ## Examples ```bash diff --git a/snippets/notifications/channels.mdx b/snippets/notifications/channels.mdx new file mode 100644 index 00000000..470b9b86 --- /dev/null +++ b/snippets/notifications/channels.mdx @@ -0,0 +1,17 @@ +## How Delivery Works + +Each notification type can be delivered over one or more channels: + +{/* HIDDEN: in-app delivery row — no console notification inbox UI yet. Restore when it ships: | **In-app** | Console notification inbox entries for supported events | */} + +| Channel | Use it for | +| --- | --- | +| **Email** | Human-readable messages for events you want in your inbox | +| **Webhooks** | Machine-readable HTTP events for your own systems | + +Some emails are mandatory because they are tied to account access, billing protection, or imminent service disruption. Mandatory email notifications cannot be fully disabled from the settings page. + + +Email and webhook preferences are set per notification type. +{/* HIDDEN: in-app inbox note — restore when the console inbox UI ships: The in-app inbox is shown automatically for supported events, so it is not a per-type preference and does not appear in `default_preferences`. */} +