ravensight-playtest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Keeps the driver out of builds that ship to players.
|
|
2
|
+
#
|
|
3
|
+
# The driver already refuses to start in an export template that lacks the
|
|
4
|
+
# "ravensight_driver" feature tag, but a refusing-to-start TCP server is still
|
|
5
|
+
# a TCP server sitting in the binary. This skips the whole addon at export
|
|
6
|
+
# time unless the preset was built with that tag, so the usual release export
|
|
7
|
+
# contains no driver code at all.
|
|
8
|
+
#
|
|
9
|
+
# One file does not simply vanish. A developer who installs the addon in their
|
|
10
|
+
# own project gets the autoload written into project.godot, and project.godot
|
|
11
|
+
# ships in the export. Removing driver.gd outright would leave that autoload
|
|
12
|
+
# pointing at a file that is not there, and the exported game would greet the
|
|
13
|
+
# player with a load error before its first frame. So driver.gd's path keeps a
|
|
14
|
+
# script: a stub that frees itself and does nothing else. Every other file in
|
|
15
|
+
# the addon, this plugin included, is dropped.
|
|
16
|
+
@tool
|
|
17
|
+
extends EditorExportPlugin
|
|
18
|
+
|
|
19
|
+
const FEATURE_TAG := "ravensight_driver"
|
|
20
|
+
const ADDON_PREFIX := "res://addons/ravensight_driver/"
|
|
21
|
+
const DRIVER_PATH := "res://addons/ravensight_driver/driver.gd"
|
|
22
|
+
|
|
23
|
+
const INERT_STUB := """extends Node
|
|
24
|
+
|
|
25
|
+
# The Ravensight playtest driver was stripped from this export. This stub only
|
|
26
|
+
# exists so a self-installed autoload entry still resolves to a real script.
|
|
27
|
+
func _ready() -> void:
|
|
28
|
+
queue_free()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
var _keep := false
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
func _get_name() -> String:
|
|
35
|
+
return "RavensightDriverStrip"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
func _export_begin(features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
|
|
39
|
+
_keep = FEATURE_TAG in features
|
|
40
|
+
if not _keep:
|
|
41
|
+
print("[ravensight-driver] stripping the playtest driver from this export")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
func _export_file(path: String, _type: String, _features: PackedStringArray) -> void:
|
|
45
|
+
if _keep:
|
|
46
|
+
return
|
|
47
|
+
if not path.begins_with(ADDON_PREFIX):
|
|
48
|
+
return
|
|
49
|
+
skip()
|
|
50
|
+
if path == DRIVER_PATH:
|
|
51
|
+
add_file(path, INERT_STUB.to_utf8_buffer(), false)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[plugin]
|
|
2
|
+
|
|
3
|
+
name="Ravensight Playtest Driver"
|
|
4
|
+
description="Opt-in JSON-RPC driver that lets Ravensight playtest personas observe and control a running Godot game. Inert unless launched with --ravensight-driver or RAVENSIGHT_DRIVER=1, binds 127.0.0.1 only, and is stripped from any export not built with the ravensight_driver feature tag."
|
|
5
|
+
author="Ravensight"
|
|
6
|
+
version="1.0.0"
|
|
7
|
+
script="plugin.gd"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Editor side of the Ravensight Playtest driver.
|
|
2
|
+
#
|
|
3
|
+
# Two jobs, both about making sure the driver cannot reach players:
|
|
4
|
+
# 1. Register (and unregister) the driver autoload, for developers who
|
|
5
|
+
# install the addon in their own project rather than letting the CLI
|
|
6
|
+
# inject it into a temporary copy.
|
|
7
|
+
# 2. Install an export plugin that strips addons/ravensight_driver/ out of
|
|
8
|
+
# every export that was not deliberately built with the custom feature
|
|
9
|
+
# tag "ravensight_driver". A release build therefore does not merely
|
|
10
|
+
# refuse to start the driver, it does not contain it.
|
|
11
|
+
@tool
|
|
12
|
+
extends EditorPlugin
|
|
13
|
+
|
|
14
|
+
const AUTOLOAD_NAME := "RavensightDriver"
|
|
15
|
+
const AUTOLOAD_PATH := "res://addons/ravensight_driver/driver.gd"
|
|
16
|
+
|
|
17
|
+
var _export_plugin: EditorExportPlugin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
func _enter_tree() -> void:
|
|
21
|
+
_export_plugin = preload("res://addons/ravensight_driver/export_plugin.gd").new()
|
|
22
|
+
add_export_plugin(_export_plugin)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
func _exit_tree() -> void:
|
|
26
|
+
if _export_plugin != null:
|
|
27
|
+
remove_export_plugin(_export_plugin)
|
|
28
|
+
_export_plugin = null
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
func _enable_plugin() -> void:
|
|
32
|
+
add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
func _disable_plugin() -> void:
|
|
36
|
+
remove_autoload_singleton(AUTOLOAD_NAME)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The only place this package calls `process.exit`.
|
|
4
|
+
*
|
|
5
|
+
* Every command answers an exit code instead of exiting, so the whole CLI can
|
|
6
|
+
* be driven from a test without taking the test process down with it. This file
|
|
7
|
+
* is the one boundary where a code becomes an exit, and the one place an
|
|
8
|
+
* unhandled rejection is turned into a message rather than a Node warning.
|
|
9
|
+
*/
|
|
10
|
+
import { main, report } from '../src/cli.js';
|
|
11
|
+
|
|
12
|
+
const NODE_MIN_MAJOR = 22;
|
|
13
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
14
|
+
if (Number.isFinite(major) && major < NODE_MIN_MAJOR) {
|
|
15
|
+
process.stderr.write(
|
|
16
|
+
`ravensight-playtest needs Node ${NODE_MIN_MAJOR} or newer, and this is v${process.versions.node}.\n`
|
|
17
|
+
);
|
|
18
|
+
process.exit(3);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
process.on('unhandledRejection', error => {
|
|
22
|
+
process.exitCode = report(error);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
main(process.argv)
|
|
26
|
+
.then(code => {
|
|
27
|
+
process.exitCode = code;
|
|
28
|
+
})
|
|
29
|
+
.catch(error => {
|
|
30
|
+
process.exitCode = report(error);
|
|
31
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ravensight-playtest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI personas play your game and file reports to Ravensight.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ravensight-playtest": "bin/ravensight-playtest.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"addons",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"!src/**/*.test.js",
|
|
20
|
+
"!src/test-helpers",
|
|
21
|
+
"!src/run/__helpers__",
|
|
22
|
+
"!src/run/drivers/testing",
|
|
23
|
+
"!test"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/api/index.js",
|
|
27
|
+
"./api": "./src/api/index.js"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e).test.js'",
|
|
31
|
+
"test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js",
|
|
32
|
+
"test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
|
|
33
|
+
"lint": "node scripts/no-dashes.mjs"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@anthropic-ai/sdk": "^0.60.0",
|
|
37
|
+
"ajv": "^8.17.1",
|
|
38
|
+
"ajv-formats": "^3.0.1",
|
|
39
|
+
"commander": "^13.0.0"
|
|
40
|
+
},
|
|
41
|
+
"optionalDependencies": {
|
|
42
|
+
"keytar": "^7.9.0",
|
|
43
|
+
"playwright": "^1.49.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# The runner-facing interface
|
|
2
|
+
|
|
3
|
+
Everything `src/run/*` (cli-runner) and `src/run/drivers/godot.js` (cli-godot)
|
|
4
|
+
are allowed to import lives behind one module:
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
import { createClient, uploadRunDir, packs } from '../api/index.js';
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Nothing else in `src/` is a contract. `src/api/http.js`, `src/auth/*`,
|
|
11
|
+
`src/upload/queue.js` and the command modules are implementation detail and
|
|
12
|
+
may change without notice. If you need something that is not listed here,
|
|
13
|
+
ask for it to be added to `src/api/index.js` rather than reaching past it.
|
|
14
|
+
|
|
15
|
+
The barrel is also the package's public entry point (`exports` in
|
|
16
|
+
`package.json`), so an external consumer sees exactly this surface.
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
| Export | What it is |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `createClient(options)` | the Ravensight API client |
|
|
23
|
+
| `ApiError`, `isRetryable(error)`, `codeRetryLimit(error)` | the error envelope, and whether a retry could help |
|
|
24
|
+
| `resolveApiUrl()`, `resolveToken(options)` | where the API is, and the credential to use |
|
|
25
|
+
| `handleUnauthorized`, `withAuthHandling` | the one 401 handler every command funnels through |
|
|
26
|
+
| `createContext(options)` | resolve config plus credential plus client in one call |
|
|
27
|
+
| `loadConfig(options)`, `saveConfig(config, options)` | `<repo>/.ravensight/config.json` |
|
|
28
|
+
| `packs` | pack cache: `packs.get()`, `packs.sync()`, `packs.cached()` |
|
|
29
|
+
| `uploadRunDir(runDir, options)` | presign, PUT, record; one run directory |
|
|
30
|
+
| `finalizeRun(runDir, options)` | `uploadRunDir` plus `complete`, with the 422 repair pass |
|
|
31
|
+
| `readState`, `writeState`, `updateState`, `stateFileFor` | the resumable job journal |
|
|
32
|
+
| `paths` | every directory and file this CLI writes |
|
|
33
|
+
| `ui` | spinner, table, status lines, confirmation prompt |
|
|
34
|
+
| `CLI_VERSION`, `compareVersions` | this package's version, sent as `cli_version` |
|
|
35
|
+
| `ExitCode`, `CliError` | the spec 17 exit codes, and the error that carries one |
|
|
36
|
+
| state lists | `MODULES`, `DRIVERS`, `RUN_STATES`, `RUN_TERMINAL_STATES`, `RUN_TRANSITIONS`, `isRunTerminal`, `canTransitionRun`, and the job equivalents |
|
|
37
|
+
| link helpers | `dashboardUrl`, `gameLink`, `jobLink`, `reviewLink`, `briefLink`, `openInBrowser` |
|
|
38
|
+
| `inspectGodot` | the Godot binary and project version, through cli-godot's own detection |
|
|
39
|
+
| machine helpers | `recommendConcurrency`, `freeBytes`, `probe` |
|
|
40
|
+
| allowlist | `contentTypeFor`, `isAllowedPath`, `JOB_UPLOAD_PATHS`, `RETENTION_TAGGED_PATHS`, the three size caps |
|
|
41
|
+
| file helpers | `sha256`, `sha256File`, `readJson`, `writeJson`, `writeFileAtomic`, `stableStringify` |
|
|
42
|
+
|
|
43
|
+
## createClient
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const api = createClient({
|
|
47
|
+
apiUrl: 'https://api.ravensight.io', // default resolveApiUrl()
|
|
48
|
+
token: 'gt_cli_...', // required for everything but cli.version()
|
|
49
|
+
cliVersion: CLI_VERSION, // default CLI_VERSION
|
|
50
|
+
fetch: globalThis.fetch, // injectable, for tests
|
|
51
|
+
retries: 4, // network and 5xx and 429 retries
|
|
52
|
+
timeoutMs: 30000,
|
|
53
|
+
onRetry: ({ attempt, delayMs, error }) => {}
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The client is stateless and cheap. Make one per process, or one per run if
|
|
58
|
+
you want the per-run token on it.
|
|
59
|
+
|
|
60
|
+
### Read-only properties
|
|
61
|
+
|
|
62
|
+
`api.apiUrl`, `api.token`, `api.cliVersion`.
|
|
63
|
+
|
|
64
|
+
### The escape hatch
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const { status, headers, body } = await api.request('POST', '/api/v1/...', {
|
|
68
|
+
query: { limit: 10 },
|
|
69
|
+
body: { ... },
|
|
70
|
+
headers: { 'Idempotency-Key': key },
|
|
71
|
+
idempotent: false, // true lets a mutation be retried
|
|
72
|
+
raw: false // true returns body as text, not JSON
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Every named method below is a thin wrapper over `request`. A non-2xx answer
|
|
77
|
+
throws `ApiError`; nothing returns an error as a value.
|
|
78
|
+
|
|
79
|
+
### api.cli
|
|
80
|
+
|
|
81
|
+
| Call | Server route | Answers |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `api.cli.version()` | `GET /api/v1/playtest/cli/version` | `{ min_supported, latest, schema_version }`. No token needed. |
|
|
84
|
+
| `api.cli.deviceCode({ client, clientVersion })` | `POST /api/v1/playtest/cli/device-code` | `{ device_code, user_code, verification_uri, expires_in, interval }` |
|
|
85
|
+
| `api.cli.pollToken(deviceCode)` | `POST /api/v1/playtest/cli/token` | `{ token, games, scopes, expires_at }`, or throws `ApiError` with code `authorization_pending` (428), `slow_down` (429), `expired_token` (400) or `access_denied` (403) |
|
|
86
|
+
| `api.cli.whoami()` | `GET /api/v1/playtest/cli/whoami` | `{ user, orgs, games: [{ gameId, name }], scopes, available_cents }` |
|
|
87
|
+
| `api.cli.revoke()` | `DELETE /api/v1/playtest/cli/token` | nothing (204) |
|
|
88
|
+
|
|
89
|
+
### api.packs
|
|
90
|
+
|
|
91
|
+
Every pack route is cacheable. Pass `etag` to get a 304 instead of a body.
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const { status, etag, body } = await api.packs.bundle({ gameId, modules, etag });
|
|
95
|
+
// status 200 -> body is the bundle; status 304 -> body is null
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`api.packs.manifest`, `api.packs.personas({ gameId, etag })`,
|
|
99
|
+
`api.packs.skills({ modules, etag })`, `api.packs.schemas({ etag })`,
|
|
100
|
+
`api.packs.routing({ etag })`, `api.packs.bundle({ gameId, modules, etag })`.
|
|
101
|
+
|
|
102
|
+
Prefer `packs.get()` (below) over these: it does the caching for you.
|
|
103
|
+
|
|
104
|
+
### api.brief
|
|
105
|
+
|
|
106
|
+
All game-scoped, all under `/api/v1/games/:gameId/playtest`.
|
|
107
|
+
|
|
108
|
+
| Call | Route |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `api.brief.get(gameId)` | `GET /brief` -> `{ brief, rendered }` or `{ brief: null }` |
|
|
111
|
+
| `api.brief.versions(gameId)` | `GET /brief/versions` |
|
|
112
|
+
| `api.brief.version(gameId, version)` | `GET /brief/versions/:version` |
|
|
113
|
+
| `api.brief.put(gameId, { fields, status, parentVersion })` | `PUT /brief` -> `{ brief, rendered }`; 409 `stale_version` carries `current_version` |
|
|
114
|
+
| `api.brief.estimate(gameId, { modules, personas })` | `POST /brief/estimate` -> `{ estimate_cents, breakdown, balance_cents, ok, topup_url }` |
|
|
115
|
+
| `api.brief.check(gameId, completeness)` | `POST /brief/check` -> `{ completeness }` |
|
|
116
|
+
|
|
117
|
+
`balance_cents` and `ok` are `null` when the caller's role has no
|
|
118
|
+
`billing:manage`. That is not an error; show the estimate without a verdict.
|
|
119
|
+
|
|
120
|
+
### api.jobs
|
|
121
|
+
|
|
122
|
+
| Call | Route |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `api.jobs.estimate(gameId, { modules, personas })` | `POST /estimate`, same body as `brief.estimate` |
|
|
125
|
+
| `api.jobs.register(gameId, body, { idempotencyKey })` | `POST /jobs` |
|
|
126
|
+
| `api.jobs.list(gameId, query)` | `GET /jobs` -> `{ jobs, next_cursor }` |
|
|
127
|
+
| `api.jobs.get(gameId, jobId)` | `GET /jobs/:jobId` -> `{ job }` |
|
|
128
|
+
| `api.jobs.patch(gameId, jobId, { state, reason })` | `PATCH /jobs/:jobId` -> `{ job }` |
|
|
129
|
+
| `api.jobs.cancel(gameId, jobId)` | `POST /jobs/:jobId/cancel` |
|
|
130
|
+
| `api.jobs.finish(gameId, jobId, { state, reason })` | `POST /jobs/:jobId/finish` |
|
|
131
|
+
| `api.jobs.remove(gameId, jobId)` | `DELETE /jobs/:jobId` |
|
|
132
|
+
| `api.jobs.uploads(gameId, jobId, { files })` | `POST /jobs/:jobId/uploads`, job-level keys |
|
|
133
|
+
| `api.jobs.capabilityReport(gameId, jobId, body)` | `POST /jobs/:jobId/capability-report` |
|
|
134
|
+
| `api.jobs.aggregateComplete(gameId, jobId, body)` | `POST /jobs/:jobId/aggregate/complete` |
|
|
135
|
+
| `api.jobs.artifacts(gameId, jobId)` | `GET /jobs/:jobId/artifacts` |
|
|
136
|
+
| `api.jobs.report(gameId, jobId, format)` | `GET /jobs/:jobId/report?format=md\|json`, returns text |
|
|
137
|
+
| `api.jobs.summary(gameId)` | `GET /summary` |
|
|
138
|
+
| `api.jobs.findings(gameId, query)` | `GET /findings` |
|
|
139
|
+
|
|
140
|
+
`register` takes the server's own spelling, so nothing is translated twice:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
await api.jobs.register(gameId, {
|
|
144
|
+
modules: ['persona_playtest'], // required, non-empty
|
|
145
|
+
personas: ['rage-quitter'], // required when modules has persona_playtest
|
|
146
|
+
driver: 'playwright_web', // required
|
|
147
|
+
cli_version: api.cliVersion, // required, added for you if omitted
|
|
148
|
+
confirm_price_cents: 800, // required integer, from the estimate
|
|
149
|
+
build: { kind: 'url', ref: 'http://localhost:8080' },
|
|
150
|
+
repo: { commit: 'abc123', dirty: false },
|
|
151
|
+
max_actions: 120,
|
|
152
|
+
budget: { wall_seconds_max: 1800 },
|
|
153
|
+
pack_versions: { pack: '1.0.0' }
|
|
154
|
+
}, { idempotencyKey });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`Idempotency-Key` is required by the server, so `register` throws
|
|
158
|
+
`TypeError` locally rather than sending a request that cannot succeed. The
|
|
159
|
+
call is marked idempotent, so a dropped response is retried and the server
|
|
160
|
+
replays instead of charging twice.
|
|
161
|
+
|
|
162
|
+
On success: `{ job_id, runs: [{ run_id, module, persona, playtest_token, budget_usd }], price: { price_cents, breakdown }, links: { dashboard }, replayed }`.
|
|
163
|
+
|
|
164
|
+
Refusals worth handling by code:
|
|
165
|
+
|
|
166
|
+
| Status | `error` | What it means |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| 400 | `idempotency_key_required`, `invalid_job` | fix the request |
|
|
169
|
+
| 402 | `insufficient_balance` | carries `estimate_cents`, `balance_cents`, `shortfall_cents`, `topup_url` |
|
|
170
|
+
| 409 | `price_changed` | carries the new `estimate_cents` and `breakdown`; re-confirm |
|
|
171
|
+
| 409 | `brief_required` | finish the brief first |
|
|
172
|
+
| 409 | `cli_outdated` | carries `min_version` |
|
|
173
|
+
| 409 | `charge_in_progress` | unknown, not refused. Honor `Retry-After` and ask again |
|
|
174
|
+
| 429 | `too_many_active_jobs` | carries `max_active_jobs` |
|
|
175
|
+
|
|
176
|
+
### api.runs
|
|
177
|
+
|
|
178
|
+
| Call | Route |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `api.runs.list(gameId, jobId)` | `GET /jobs/:jobId/runs` -> `{ runs }` |
|
|
181
|
+
| `api.runs.add(gameId, jobId, personas)` | `POST /jobs/:jobId/runs` |
|
|
182
|
+
| `api.runs.heartbeat(gameId, jobId, runId, opts)` | `PATCH /jobs/:jobId/runs/:runId` with no state |
|
|
183
|
+
| `api.runs.transition(gameId, jobId, runId, state, opts)` | the same PATCH, with a state |
|
|
184
|
+
| `api.runs.uploads(gameId, jobId, runId, { files, includeVideo })` | `POST .../uploads` |
|
|
185
|
+
| `api.runs.complete(gameId, jobId, runId, body)` | `POST .../complete` |
|
|
186
|
+
|
|
187
|
+
`opts` for both heartbeat and transition is
|
|
188
|
+
`{ reason, checkpointStep, actionsTaken, heartbeat }`. Both answer
|
|
189
|
+
`{ run, cancel_requested }`, and `cancel_requested` is the only signal a
|
|
190
|
+
runner gets that it should stop, so read it on every beat.
|
|
191
|
+
|
|
192
|
+
Heartbeat is marked idempotent and is retried. Transition is not: a retried
|
|
193
|
+
transition can lose a race and come back 409 `invalid_transition`, which the
|
|
194
|
+
caller has to see rather than have swallowed.
|
|
195
|
+
|
|
196
|
+
Send a heartbeat at least every 5 seconds. The server marks a run
|
|
197
|
+
`interrupted` after 30 minutes of silence.
|
|
198
|
+
|
|
199
|
+
## ApiError
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
try { await api.jobs.register(...); }
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof ApiError) {
|
|
205
|
+
error.status; // 402
|
|
206
|
+
error.code; // 'insufficient_balance', the server's `error` field
|
|
207
|
+
error.message; // the server's `message`, safe to print
|
|
208
|
+
error.details; // [{ instancePath, message, code? }], [] when absent
|
|
209
|
+
error.retryAfter; // seconds, or null
|
|
210
|
+
error.body; // the parsed body, for the extra figures
|
|
211
|
+
error.method; // 'POST'
|
|
212
|
+
error.path; // '/api/v1/games/g1/playtest/jobs'
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`isRetryable(error)` is true for a network failure, a timeout, 408, 425, 429
|
|
218
|
+
and 5xx, with two code-aware exceptions, because for these two the status alone
|
|
219
|
+
gives the wrong answer:
|
|
220
|
+
|
|
221
|
+
- **`too_many_active_jobs`** is a 429 and is NOT retried. It means this game
|
|
222
|
+
already has three jobs running, which waiting inside one request cannot
|
|
223
|
+
change. The person needs to be told, not made to wait through four backoffs.
|
|
224
|
+
- **`charge_in_progress`** is a 409 and IS retried, on its own `Retry-After`,
|
|
225
|
+
up to five times, and that allowance is independent of the client's `retries`
|
|
226
|
+
setting. It does not mean refused: another caller holds the charge claim and
|
|
227
|
+
this one cannot tell whether money moved. It becomes a real answer (a replay,
|
|
228
|
+
or a charge) within seconds. A `charge_in_progress` with no `Retry-After` is
|
|
229
|
+
not retried, because then there is no interval the server has blessed.
|
|
230
|
+
|
|
231
|
+
`isRetryable` says nothing about whether retrying is *safe*; that is what the
|
|
232
|
+
`idempotent` flag is for. `codeRetryLimit(error)` exposes the per-code
|
|
233
|
+
allowance.
|
|
234
|
+
|
|
235
|
+
A network failure with no response is an `ApiError` with `status: 0` and
|
|
236
|
+
`code: 'network_error'`.
|
|
237
|
+
|
|
238
|
+
## Credentials, and what a 401 means
|
|
239
|
+
|
|
240
|
+
`resolveToken({ apiUrl, required })` answers
|
|
241
|
+
`{ token, source, host }`, where `source` is the real origin: `'env'` for
|
|
242
|
+
`RAVENSIGHT_TOKEN`, or `'keychain'` or `'file'` depending on which store
|
|
243
|
+
actually answered.
|
|
244
|
+
|
|
245
|
+
Every command runs its API work inside `withAuthHandling(context, fn)`, which
|
|
246
|
+
funnels a 401 through `handleUnauthorized(context, error)`:
|
|
247
|
+
|
|
248
|
+
- a **stored** token (`tokenSource` is not `'env'`) is DELETED, a line says so,
|
|
249
|
+
and the thrown error is a `CliError` with `ExitCode.AUTH` and a "run login"
|
|
250
|
+
hint. Keeping a revoked token means every later command fails the same way
|
|
251
|
+
and nobody can tell why.
|
|
252
|
+
- a token from **`RAVENSIGHT_TOKEN`** is never deleted, only reported. It is
|
|
253
|
+
probably a CI secret shared with other jobs.
|
|
254
|
+
- anything that is not a 401 (a 403, a 404, a plain `Error`) passes through
|
|
255
|
+
untouched.
|
|
256
|
+
|
|
257
|
+
If you add a command, wrap its body the same way. `createContext` gives you the
|
|
258
|
+
`tokenSource` it needs.
|
|
259
|
+
|
|
260
|
+
## packs
|
|
261
|
+
|
|
262
|
+
The cache lives in the user cache directory, keyed by API host, verified by
|
|
263
|
+
sha256, refreshed with `If-None-Match`.
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
const { bundle, etag, fromCache, fetchedAt, stale } = await packs.get({
|
|
267
|
+
api, gameId, modules: ['persona_playtest'], offline: false
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- Online, no cache: fetches and writes the cache.
|
|
272
|
+
- Online, cache present: sends `If-None-Match`; a 304 reuses the cached copy.
|
|
273
|
+
- Offline (or the fetch fails): returns the cached copy with `stale: true`,
|
|
274
|
+
or throws when there is no cache at all.
|
|
275
|
+
- `fetchedAt` is an ISO string. `stale` is true whenever the answer did not
|
|
276
|
+
come from this process's own successful request.
|
|
277
|
+
|
|
278
|
+
`packs.cached({ apiUrl, gameId, modules })` reads the cache without any
|
|
279
|
+
network. `packs.sync({ api, gameId, modules })` forces a fetch and returns
|
|
280
|
+
the same shape.
|
|
281
|
+
|
|
282
|
+
The bundle's own fields are the server's: `pack_version`, `generated_at`,
|
|
283
|
+
`personas`, `skills`, `schemas`, `routing`, `code_brief`, `engine`,
|
|
284
|
+
`intent_notes`, `etag`.
|
|
285
|
+
|
|
286
|
+
## uploadRunDir and finalizeRun
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
const result = await uploadRunDir(runDir, {
|
|
290
|
+
api, gameId, jobId, runId,
|
|
291
|
+
includeVideo: false, // session.webm and session.mp4 are opt-in
|
|
292
|
+
includeTranscript: false, // transcript.jsonl is opt-in
|
|
293
|
+
allowedPaths: null, // narrow the allowlist (see JOB_UPLOAD_PATHS)
|
|
294
|
+
dryRun: false, // list what would be sent, upload nothing
|
|
295
|
+
onFile: ({ path, size, status }) => {}
|
|
296
|
+
});
|
|
297
|
+
// { uploaded: [{path,size,sha256}], skipped: [{path,reason}], bytes, dryRun }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**The server has ONE opt-in flag and it covers BOTH opt-in artifacts.**
|
|
301
|
+
`POST .../uploads` takes `include_video`, and the server's `contentTypeFor`
|
|
302
|
+
skips every `optIn` entry when it is false, which includes `transcript.jsonl`.
|
|
303
|
+
So a transcript-only batch sent with `include_video: false` is refused, and the
|
|
304
|
+
refusal is a 400 on the whole batch rather than on the one path. `uploadRunDir`
|
|
305
|
+
therefore sends `include_video: includeVideo || includeTranscript`. Keep the two
|
|
306
|
+
CLI-side flags separate (they decide what goes in the batch); do not try to
|
|
307
|
+
send two flags to the server.
|
|
308
|
+
|
|
309
|
+
For a job-level upload (no `runId`), pass `allowedPaths: JOB_UPLOAD_PATHS`. The
|
|
310
|
+
server's allowlist is one list for both levels and would happily sign
|
|
311
|
+
`report.json` or `job.json` at the job root; the three artifacts that actually
|
|
312
|
+
belong to a job are `capability-report.json`, `aggregate-report.md` and
|
|
313
|
+
`aggregate-report.json`.
|
|
314
|
+
|
|
315
|
+
`runDir` is a directory on disk. Files are matched against the upload
|
|
316
|
+
allowlist compiled into the CLI (the same list as the server's
|
|
317
|
+
`src/playtest/limits.js`); anything else is reported in `skipped` and never
|
|
318
|
+
sent. Source code is not on the list and is never uploaded.
|
|
319
|
+
|
|
320
|
+
Each file is sha256'd and sized locally, presigned in one request, then PUT
|
|
321
|
+
with the `Content-Type` the server signed and, for `session.webm`,
|
|
322
|
+
`session.mp4` and `transcript.jsonl`, the `x-amz-tagging` value the presign
|
|
323
|
+
response handed back, sent verbatim. Dropping that header is a signature
|
|
324
|
+
mismatch, not an untagged object.
|
|
325
|
+
|
|
326
|
+
A 403 on a PUT means the URL expired: the file is presigned again once and
|
|
327
|
+
retried. Anything else retries with backoff. Every attempt is journalled to
|
|
328
|
+
`<jobDir>/upload-queue.jsonl`, so `ravensight-playtest upload <job>` can
|
|
329
|
+
drain what a crash left behind, and a file whose sha256 is already recorded
|
|
330
|
+
as uploaded is skipped rather than sent twice.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
const { run, dropped_findings, report_lint } = await finalizeRun(runDir, {
|
|
334
|
+
api, gameId, jobId, runId, includeVideo, includeTranscript,
|
|
335
|
+
body: { state: 'succeeded', report, report_md, actions_taken, quit_reason }
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`finalizeRun` uploads, then calls `complete`. A 422 `upload_unverified`
|
|
340
|
+
names the failed paths in `details[].instancePath` as `/manifest/<path>`;
|
|
341
|
+
`finalizeRun` re-uploads exactly those and completes once more. The run
|
|
342
|
+
stays non-terminal through that, by design. A second failure throws.
|
|
343
|
+
|
|
344
|
+
422 `invalid_report` and 422 `secrets_detected` are not repaired here and
|
|
345
|
+
are thrown for the caller to deal with: a report the schema refuses needs
|
|
346
|
+
regenerating, and a secret needs rotating.
|
|
347
|
+
|
|
348
|
+
## State
|
|
349
|
+
|
|
350
|
+
The resumable journal, one file per job, written atomically (temp file plus
|
|
351
|
+
rename) so a kill mid-write cannot leave a half file.
|
|
352
|
+
|
|
353
|
+
```js
|
|
354
|
+
await writeState(jobId, { job_id, game_id, ... }, { repoRoot });
|
|
355
|
+
const state = await readState(jobId, { repoRoot }); // null when absent
|
|
356
|
+
await updateState(jobId, s => ({ ...s, runs: {...} }), { repoRoot });
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The shape is the runner's to define; cli-core only requires `job_id` and
|
|
360
|
+
`game_id` at the top level, which is what `resume` reads. Keep every value
|
|
361
|
+
JSON-serializable.
|
|
362
|
+
|
|
363
|
+
## paths
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
paths.repoRoot(options) // cwd, or options.repoRoot
|
|
367
|
+
paths.ravensightDir(options) // <repo>/.ravensight
|
|
368
|
+
paths.configFile(options) // <repo>/.ravensight/config.json
|
|
369
|
+
paths.jobDir(jobId, options) // <repo>/.ravensight/jobs/<jobId>
|
|
370
|
+
paths.stateFile(jobId, options)
|
|
371
|
+
paths.queueFile(jobId, options)
|
|
372
|
+
paths.runDir(jobId, runId, options) // .../jobs/<jobId>/runs/<runId>
|
|
373
|
+
paths.userDir() // ~/.ravensight-playtest
|
|
374
|
+
paths.cacheDir(apiUrl) // per-host pack cache
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
A run directory holds `report.md`, `report.json`, `usage.json`,
|
|
378
|
+
`transcript.jsonl`, `screenshots/NN-slug.png` and `session.webm`. Those
|
|
379
|
+
names are the allowlist's; a different name is not uploadable.
|
|
380
|
+
|
|
381
|
+
## ui
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
ui.info(text); ui.warn(text); ui.error(text); ui.ok(text);
|
|
385
|
+
ui.table(rows, columns); // aligned, no borders
|
|
386
|
+
const spin = ui.spinner('Working'); spin.update('Still working'); spin.stop('Done');
|
|
387
|
+
await ui.confirm('Start the job?', { yes: flags.yes });
|
|
388
|
+
ui.json(value); // stable stringify for --json output
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Nothing in `ui` writes to stdout when `RAVENSIGHT_JSON=1` or when
|
|
392
|
+
`ui.setQuiet(true)` has been called, except `ui.json`, so `--json` output
|
|
393
|
+
stays machine-readable. Spinners degrade to one line per update when stdout
|
|
394
|
+
is not a TTY.
|
|
395
|
+
|
|
396
|
+
## State lists
|
|
397
|
+
|
|
398
|
+
`MODULES`, `DRIVERS`, `JOB_STATES`, `JOB_TERMINAL_STATES`, `RUN_STATES`,
|
|
399
|
+
`RUN_TERMINAL_STATES` and `RUN_TRANSITIONS` are copies of the server's own
|
|
400
|
+
enums, with `isRunTerminal(state)`, `isJobTerminal(state)` and
|
|
401
|
+
`canTransitionRun(from, to)` over them. Use them to refuse a nonsense
|
|
402
|
+
transition locally; the server is still the authority and refuses one it does
|
|
403
|
+
not know.
|
|
404
|
+
|
|
405
|
+
Two of them are load bearing. `interrupted` is NOT a terminal run state: it is
|
|
406
|
+
what the server's sweep marks a run whose heartbeat stopped, and the whole
|
|
407
|
+
point of it is that `resume` can take it back to `launching`. And a job reaches
|
|
408
|
+
a terminal state only through `/finish`, `/cancel` or `DELETE`; a `PATCH` that
|
|
409
|
+
asks for one is refused with 409, because the terminal state and the refund are
|
|
410
|
+
one decision.
|
|
411
|
+
|
|
412
|
+
## Godot
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
const { isProject, projectVersion, binary, matches, source } = await inspectGodot({
|
|
416
|
+
repoRoot, godotPath
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
This is an adapter, not a second implementation. When
|
|
421
|
+
`src/run/drivers/godot-project.js` is present it calls that module's
|
|
422
|
+
`isGodotProject`, `readProjectEngineVersion` and `detectGodotBinary`, so the
|
|
423
|
+
binary `check` blesses is the one the driver will launch; without it, it falls
|
|
424
|
+
back to a local probe and reports `source: 'fallback'`. Godot is never a
|
|
425
|
+
failure in `check`: a missing one is a warning, because a web build run does
|
|
426
|
+
not need it.
|
|
427
|
+
|
|
428
|
+
## Exit codes
|
|
429
|
+
|
|
430
|
+
`ExitCode` from spec 17, used by `process.exit` at the top level only:
|
|
431
|
+
|
|
432
|
+
| Name | Value |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `OK` | 0 |
|
|
435
|
+
| `BUDGET_EXCEEDED` | 2 |
|
|
436
|
+
| `ENVIRONMENT` | 3 |
|
|
437
|
+
| `AUTH` | 4 |
|
|
438
|
+
| `MODEL` | 5 |
|
|
439
|
+
| `CANCELED` | 10 |
|
|
440
|
+
|
|
441
|
+
Throw `new CliError(message, ExitCode.AUTH)` from anywhere; `bin/` maps it
|
|
442
|
+
to the code and prints the message without a stack trace.
|
|
443
|
+
|
|
444
|
+
## What cli-runner must export
|
|
445
|
+
|
|
446
|
+
`src/cli.js` wires three commands to a lazy `import('./run/index.js')`, which
|
|
447
|
+
keeps `check` from parsing the whole runner and turns a partial install into a
|
|
448
|
+
sentence rather than a module-resolution stack trace. `src/run/index.js` exports
|
|
449
|
+
exactly these three names, and `src/cli.test.js` resolves the module and checks
|
|
450
|
+
for all three, so a rename on either side of the seam fails a test rather than a
|
|
451
|
+
paid run.
|
|
452
|
+
|
|
453
|
+
```js
|
|
454
|
+
/**
|
|
455
|
+
* `ravensight-playtest run`
|
|
456
|
+
* @param {Object} flags - { apiUrl, repoRoot, json, yes, game, argv }
|
|
457
|
+
* `argv` is every unrecognized argument, so the run command owns its own
|
|
458
|
+
* flag parsing (--personas, --driver, --build-url, --max-actions, and so on).
|
|
459
|
+
* @returns {Promise<number>} an ExitCode
|
|
460
|
+
*/
|
|
461
|
+
export async function runCommand(flags) {}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* `ravensight-playtest profile`
|
|
465
|
+
* @param {Object} flags - the same shape
|
|
466
|
+
* @returns {Promise<number>} an ExitCode
|
|
467
|
+
*/
|
|
468
|
+
export async function profileCommand(flags) {}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The handoff from `ravensight-playtest resume <jobId>`.
|
|
472
|
+
*
|
|
473
|
+
* cli-core has already: read the local journal, confirmed the job exists on the
|
|
474
|
+
* server, listed its runs, and printed which are finished. It does NOT restart
|
|
475
|
+
* anything, and it never filters the runs it hands over, so deciding which are
|
|
476
|
+
* resumable is yours (use `isRunTerminal` from this module: a `succeeded` run
|
|
477
|
+
* must never be re-run, and `interrupted` is the one that should be).
|
|
478
|
+
*
|
|
479
|
+
* @param {Object} args
|
|
480
|
+
* @param {Object} args.api a createClient instance, already authenticated
|
|
481
|
+
* @param {string} args.gameId
|
|
482
|
+
* @param {string} args.jobId
|
|
483
|
+
* @param {Object} args.job the server's job body, from GET /jobs/:jobId
|
|
484
|
+
* @param {Array} args.runs the server's run bodies, from GET .../runs
|
|
485
|
+
* @param {Object} args.state the local journal, as written by writeState
|
|
486
|
+
* @param {string} args.repoRoot
|
|
487
|
+
* @param {Object} args.flags { apiUrl, repoRoot, json, yes, game, status,
|
|
488
|
+
* restartInterrupted }. `restartInterrupted`
|
|
489
|
+
* is `resume --restart-interrupted`: without
|
|
490
|
+
* it a run past the partial checkpoint floor
|
|
491
|
+
* is finalised from its own transcript, with
|
|
492
|
+
* it the run is replayed and spends its
|
|
493
|
+
* action budget again.
|
|
494
|
+
* @returns {Promise<number>} an ExitCode. 2 for a budget stop, 10 for a cancel.
|
|
495
|
+
*/
|
|
496
|
+
export async function resumeJob(args) {}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Both commands must answer an exit code rather than calling `process.exit`:
|
|
500
|
+
`bin/ravensight-playtest.js` is the only place in the package that exits.
|