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,191 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { paths } from '../paths.js';
|
|
3
|
+
import { readJson, sha256, stableStringify, writeJson } from '../fsutil.js';
|
|
4
|
+
import { CliError } from '../errors.js';
|
|
5
|
+
import { ApiError } from '../api/errors.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The content pack cache.
|
|
9
|
+
*
|
|
10
|
+
* Packs are data (markdown, JSON), never code, and they are fetched from the
|
|
11
|
+
* server rather than shipped in the npm package so a persona or a schema can
|
|
12
|
+
* be improved without a release. That makes three things necessary:
|
|
13
|
+
*
|
|
14
|
+
* - a conditional GET (`If-None-Match`), so the common case is a 304 and not a
|
|
15
|
+
* megabyte on every run;
|
|
16
|
+
* - a sha256 over the cached body, verified on read, so a truncated write or a
|
|
17
|
+
* half finished download cannot be mistaken for content;
|
|
18
|
+
* - an offline path, because spec 17 requires a run to work after one online
|
|
19
|
+
* fetch, with the answer honestly labelled `stale`.
|
|
20
|
+
*
|
|
21
|
+
* The cache is keyed by API host (two servers can serve different packs under
|
|
22
|
+
* the same version) and by the request that produced it (game id and module
|
|
23
|
+
* list), so a bundle fetched for one game is never served for another.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const CACHE_FORMAT = 1;
|
|
27
|
+
|
|
28
|
+
function cacheFile({ apiUrl, gameId, modules }) {
|
|
29
|
+
const key = paths.cacheKey({ gameId: gameId || null, modules: modules || null });
|
|
30
|
+
return join(paths.packCacheDir(apiUrl), `bundle-${key}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} PackResult
|
|
35
|
+
* @property {Object} bundle - the server's bundle body
|
|
36
|
+
* @property {string|null} etag
|
|
37
|
+
* @property {boolean} fromCache
|
|
38
|
+
* @property {string} fetchedAt - ISO, when the bundle was last actually fetched
|
|
39
|
+
* @property {boolean} stale - true when this process did not fetch it
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
function resultFrom(entry, { fromCache, stale }) {
|
|
43
|
+
return {
|
|
44
|
+
bundle: entry.bundle,
|
|
45
|
+
etag: entry.etag || null,
|
|
46
|
+
fromCache,
|
|
47
|
+
fetchedAt: entry.fetched_at,
|
|
48
|
+
stale
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the cache without touching the network. Answers null when there is no
|
|
54
|
+
* entry, and throws when there is one whose hash does not match its body: a
|
|
55
|
+
* corrupt cache has to be visible, because silently refetching would hide a
|
|
56
|
+
* disk that is losing writes.
|
|
57
|
+
*
|
|
58
|
+
* @param {{apiUrl: string, gameId?: string, modules?: string[]}} options
|
|
59
|
+
* @returns {Promise<PackResult|null>}
|
|
60
|
+
*/
|
|
61
|
+
export async function cached(options) {
|
|
62
|
+
const file = cacheFile(options);
|
|
63
|
+
const entry = await readJson(file, null);
|
|
64
|
+
if (!entry || typeof entry !== 'object' || entry.cache_format !== CACHE_FORMAT) return null;
|
|
65
|
+
const expected = entry.sha256;
|
|
66
|
+
const actual = sha256(stableStringify(entry.bundle));
|
|
67
|
+
if (expected !== actual) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
`The cached content pack at ${file} does not match its checksum.`,
|
|
70
|
+
1,
|
|
71
|
+
{ hint: 'Delete that file and run the command again to refetch it.' }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return resultFrom(entry, { fromCache: true, stale: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writeCache(options, { bundle, etag }) {
|
|
78
|
+
const entry = {
|
|
79
|
+
cache_format: CACHE_FORMAT,
|
|
80
|
+
api_url: options.apiUrl,
|
|
81
|
+
game_id: options.gameId || null,
|
|
82
|
+
modules: options.modules || null,
|
|
83
|
+
etag: etag || null,
|
|
84
|
+
fetched_at: new Date().toISOString(),
|
|
85
|
+
sha256: sha256(stableStringify(bundle)),
|
|
86
|
+
bundle
|
|
87
|
+
};
|
|
88
|
+
await writeJson(cacheFile(options), entry);
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fetch the bundle, ignoring any cached copy's ETag. Used by `check` and by an
|
|
94
|
+
* explicit pack refresh.
|
|
95
|
+
* @param {{api: Object, gameId?: string, modules?: string[]}} options
|
|
96
|
+
* @returns {Promise<PackResult>}
|
|
97
|
+
*/
|
|
98
|
+
export async function sync({ api, gameId, modules }) {
|
|
99
|
+
const answer = await api.packs.bundle({ gameId, modules });
|
|
100
|
+
const entry = await writeCache({ apiUrl: api.apiUrl, gameId, modules }, {
|
|
101
|
+
bundle: answer.body,
|
|
102
|
+
etag: answer.etag
|
|
103
|
+
});
|
|
104
|
+
return resultFrom(entry, { fromCache: false, stale: false });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The pack a run needs.
|
|
109
|
+
*
|
|
110
|
+
* Online with a cache: send `If-None-Match` and reuse the cached body on a 304,
|
|
111
|
+
* which is the normal case and costs one small request.
|
|
112
|
+
* Online without a cache: fetch and write one.
|
|
113
|
+
* Offline, or a fetch that fails for a reason a retry would not fix: fall back
|
|
114
|
+
* to the cache and label it `stale: true`. With no cache at all there is
|
|
115
|
+
* nothing to fall back to, so it throws rather than running with no personas.
|
|
116
|
+
*
|
|
117
|
+
* An authentication or authorization failure is never treated as "offline":
|
|
118
|
+
* falling back to a cache on a 401 would let a revoked token keep working.
|
|
119
|
+
*
|
|
120
|
+
* @param {{api: Object, gameId?: string, modules?: string[], offline?: boolean}} options
|
|
121
|
+
* @returns {Promise<PackResult>}
|
|
122
|
+
*/
|
|
123
|
+
export async function get({ api, gameId, modules, offline = false }) {
|
|
124
|
+
const key = { apiUrl: api.apiUrl, gameId, modules };
|
|
125
|
+
const existing = await cached(key);
|
|
126
|
+
|
|
127
|
+
if (offline) {
|
|
128
|
+
if (!existing) {
|
|
129
|
+
throw new CliError(
|
|
130
|
+
'No cached content pack, and offline was requested.',
|
|
131
|
+
1,
|
|
132
|
+
{ hint: 'Run once while online, or drop the offline flag.' }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const answer = await api.packs.bundle({ gameId, modules, etag: existing ? existing.etag : null });
|
|
140
|
+
if (answer.status === 304 && existing) {
|
|
141
|
+
// Refresh `fetched_at` so "using cached content from N days ago" is
|
|
142
|
+
// about the last time the server confirmed it, not the last time the
|
|
143
|
+
// body changed.
|
|
144
|
+
const entry = await writeCache(key, { bundle: existing.bundle, etag: existing.etag });
|
|
145
|
+
return resultFrom(entry, { fromCache: true, stale: false });
|
|
146
|
+
}
|
|
147
|
+
const entry = await writeCache(key, { bundle: answer.body, etag: answer.etag });
|
|
148
|
+
return resultFrom(entry, { fromCache: false, stale: false });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// Any 5xx is the server being unavailable, and they are all the same thing
|
|
151
|
+
// from here: a 500 from a crashed handler is no more a reason to refuse to
|
|
152
|
+
// run than the 503 a load balancer sends. A 4xx is not: it is an answer
|
|
153
|
+
// about this request, and falling back to a cache on a 401 would let a
|
|
154
|
+
// revoked token keep working.
|
|
155
|
+
const fatal = error instanceof ApiError && !error.isNetwork && error.status < 500;
|
|
156
|
+
if (fatal || !existing) throw error;
|
|
157
|
+
return existing;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify a manifest's component ETags against a bundle we hold, which is what
|
|
163
|
+
* `check`'s "pack cache" row reports. Answers the components whose hash the
|
|
164
|
+
* bundle does not account for.
|
|
165
|
+
*
|
|
166
|
+
* The server's ETags are `sha256-<hex>` over each component, and the bundle
|
|
167
|
+
* carries the components themselves, so this is a real integrity check and not
|
|
168
|
+
* a version string comparison.
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} manifest - the `GET /packs/manifest` body
|
|
171
|
+
* @param {Object} bundle - a bundle body
|
|
172
|
+
* @returns {{ok: boolean, checked: number, mismatched: string[]}}
|
|
173
|
+
*/
|
|
174
|
+
export function verifyAgainstManifest(manifest, bundle) {
|
|
175
|
+
const components = Array.isArray(manifest && manifest.components) ? manifest.components : [];
|
|
176
|
+
const have = new Map();
|
|
177
|
+
for (const persona of bundle.personas || []) have.set(`personas/${persona.slug}`, persona.etag);
|
|
178
|
+
for (const skill of bundle.skills || []) have.set(`skills/${skill.name}`, skill.etag);
|
|
179
|
+
for (const schema of bundle.schemas || []) have.set(`schemas/${schema.name}`, schema.etag);
|
|
180
|
+
|
|
181
|
+
const mismatched = [];
|
|
182
|
+
let checked = 0;
|
|
183
|
+
for (const component of components) {
|
|
184
|
+
if (!have.has(component.name)) continue;
|
|
185
|
+
checked += 1;
|
|
186
|
+
if (have.get(component.name) !== component.etag) mismatched.push(component.name);
|
|
187
|
+
}
|
|
188
|
+
return { ok: mismatched.length === 0, checked, mismatched };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const packs = { get, sync, cached, verifyAgainstManifest, cacheFile };
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { homedir, tmpdir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_API_URL = 'https://api.ravensight.io';
|
|
6
|
+
const USER_DIR_NAME = 'ravensight-playtest';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where the API is. `RAVENSIGHT_API_URL` overrides it, which is how a
|
|
10
|
+
* developer points at a local server and how the test suite points at an
|
|
11
|
+
* in-process one. A trailing slash is stripped so every path join is
|
|
12
|
+
* predictable.
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function resolveApiUrl() {
|
|
16
|
+
const raw = process.env.RAVENSIGHT_API_URL || DEFAULT_API_URL;
|
|
17
|
+
return raw.replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The host part of an API URL, used as the cache and keychain key. Two
|
|
22
|
+
* deployments must not share a token or a pack cache, and the host is the
|
|
23
|
+
* only part of the URL that identifies one.
|
|
24
|
+
* @param {string} [apiUrl]
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function apiHost(apiUrl = resolveApiUrl()) {
|
|
28
|
+
try {
|
|
29
|
+
return new URL(apiUrl).host;
|
|
30
|
+
} catch {
|
|
31
|
+
return apiUrl;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function repoRoot(options = {}) {
|
|
36
|
+
return options.repoRoot || process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Per user state, outside any repo: the credential fallback file and the caches. */
|
|
40
|
+
function userDir() {
|
|
41
|
+
return process.env.RAVENSIGHT_HOME || join(homedir(), `.${USER_DIR_NAME}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The OS cache directory for this CLI, per API host. Pack content is keyed by
|
|
46
|
+
* host because two servers can ship different packs under the same version.
|
|
47
|
+
* @param {string} [apiUrl]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function cacheDir(apiUrl = resolveApiUrl()) {
|
|
51
|
+
if (process.env.RAVENSIGHT_CACHE_DIR) {
|
|
52
|
+
return join(process.env.RAVENSIGHT_CACHE_DIR, apiHost(apiUrl));
|
|
53
|
+
}
|
|
54
|
+
const host = apiHost(apiUrl);
|
|
55
|
+
if (process.platform === 'darwin') {
|
|
56
|
+
return join(homedir(), 'Library', 'Caches', USER_DIR_NAME, host);
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
const base = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
|
|
60
|
+
return join(base, USER_DIR_NAME, 'Cache', host);
|
|
61
|
+
}
|
|
62
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
63
|
+
return join(base, USER_DIR_NAME, host);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A filesystem safe fragment for a cache key that may contain anything: the
|
|
68
|
+
* modules list, a game id. Hashed rather than sanitized, so two keys that
|
|
69
|
+
* sanitize to the same name cannot collide.
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function cacheKey(value) {
|
|
74
|
+
return createHash('sha256').update(JSON.stringify(value === undefined ? null : value)).digest('hex').slice(0, 16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const paths = {
|
|
78
|
+
repoRoot,
|
|
79
|
+
userDir,
|
|
80
|
+
cacheDir,
|
|
81
|
+
cacheKey,
|
|
82
|
+
tmpDir: () => tmpdir(),
|
|
83
|
+
|
|
84
|
+
/** `<repo>/.ravensight`, everything this CLI writes into the developer's repo. */
|
|
85
|
+
ravensightDir: (options = {}) => join(repoRoot(options), '.ravensight'),
|
|
86
|
+
|
|
87
|
+
/** The repo to game link, written by `init`. */
|
|
88
|
+
configFile: (options = {}) => join(repoRoot(options), '.ravensight', 'config.json'),
|
|
89
|
+
|
|
90
|
+
jobsDir: (options = {}) => join(repoRoot(options), '.ravensight', 'jobs'),
|
|
91
|
+
jobDir: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId),
|
|
92
|
+
|
|
93
|
+
/** The resumable journal for one job. */
|
|
94
|
+
stateFile: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId, 'state.json'),
|
|
95
|
+
|
|
96
|
+
/** Pending and finished uploads for one job, append only. */
|
|
97
|
+
queueFile: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId, 'upload-queue.jsonl'),
|
|
98
|
+
|
|
99
|
+
/** Where a run's artifacts are written, and what `uploadRunDir` is pointed at. */
|
|
100
|
+
runDir: (jobId, runId, options = {}) =>
|
|
101
|
+
join(repoRoot(options), '.ravensight', 'jobs', jobId, 'runs', runId),
|
|
102
|
+
|
|
103
|
+
/** The 0600 credential file, used only when no OS keychain is available. */
|
|
104
|
+
credentialsFile: () => join(userDir(), 'credentials.json'),
|
|
105
|
+
|
|
106
|
+
/** The pack cache directory for one API host. */
|
|
107
|
+
packCacheDir: (apiUrl) => join(cacheDir(apiUrl), 'packs')
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The run relative names the upload allowlist recognizes. Written here rather
|
|
112
|
+
* than in nine command modules, so a writer and the uploader cannot disagree
|
|
113
|
+
* about where a report goes.
|
|
114
|
+
*/
|
|
115
|
+
export const RUN_FILES = Object.freeze({
|
|
116
|
+
reportMd: 'report.md',
|
|
117
|
+
reportJson: 'report.json',
|
|
118
|
+
usageJson: 'usage.json',
|
|
119
|
+
transcript: 'transcript.jsonl',
|
|
120
|
+
video: 'session.webm',
|
|
121
|
+
screenshotsDir: 'screenshots'
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** The job relative names, for artifacts that belong to a job and not a run. */
|
|
125
|
+
export const JOB_FILES = Object.freeze({
|
|
126
|
+
capabilityReport: 'capability-report.json',
|
|
127
|
+
aggregateMd: 'aggregate-report.md',
|
|
128
|
+
aggregateJson: 'aggregate-report.json'
|
|
129
|
+
});
|