ravensight-playtest 0.5.2 → 0.6.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.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -6,6 +6,8 @@ import { withAuthHandling } from '../auth/session.js';
6
6
  import { paths } from '../paths.js';
7
7
  import { ui } from '../ui/index.js';
8
8
  import { CliError, ExitCode } from '../errors.js';
9
+ import { recommendConcurrency } from '../detect.js';
10
+ import { isGodotProject } from '../run/drivers/godot-project.js';
9
11
 
10
12
  const GITIGNORE_LINE = '.ravensight/jobs/';
11
13
 
@@ -66,7 +68,7 @@ export async function init(flags = {}, deps = {}) {
66
68
  // Every API call in this command goes through the one 401 handler, so a
67
69
  // refused credential is cleared and explained here the same way it is
68
70
  // everywhere else.
69
- return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose));
71
+ return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose, deps.recommend || recommendConcurrency));
70
72
  }
71
73
 
72
74
  /**
@@ -76,7 +78,7 @@ export async function init(flags = {}, deps = {}) {
76
78
  * @param {(question: string, labels: Array<string>) => Promise<number|null>} choose
77
79
  * the picker; answers null when there is no terminal to ask on
78
80
  */
79
- async function linkRepo(context, flags, repoRoot, choose) {
81
+ async function linkRepo(context, flags, repoRoot, choose, recommend) {
80
82
  const whoami = await context.api.cli.whoami();
81
83
 
82
84
  const games = whoami.games || [];
@@ -130,8 +132,20 @@ async function linkRepo(context, flags, repoRoot, choose) {
130
132
  );
131
133
  }
132
134
 
135
+ // What check would say: a repository with a project.godot is played with
136
+ // the Godot driver, and the concurrency is the one this machine can take.
137
+ // 0.5.0 wrote playwright_web and 2 right after check said Godot and 1.
138
+ const godot = Boolean(await isGodotProject(repoRoot));
139
+ const machine = recommend();
133
140
  const config = await saveConfig(
134
- { ...(existing || {}), ...defaultConfig(gameId), api_url: context.apiUrl },
141
+ {
142
+ ...(existing || {}),
143
+ ...defaultConfig(gameId, {
144
+ concurrency: machine.recommended,
145
+ ...(godot ? { driver: 'godot_driver', build: { kind: 'godot_project', ref: '.' } } : {})
146
+ }),
147
+ api_url: context.apiUrl
148
+ },
135
149
  { repoRoot }
136
150
  );
137
151
  const gitignore = await ensureGitignore(repoRoot);
@@ -144,6 +158,8 @@ async function linkRepo(context, flags, repoRoot, choose) {
144
158
  const name = (games.find(game => game.gameId === gameId) || {}).name || gameId;
145
159
  ui.ok(`Linked this repo to ${name} (${gameId}).`);
146
160
  ui.info(`Wrote ${paths.configFile({ repoRoot })}`);
161
+ if (godot) ui.info('This is a Godot project, so runs use godot_driver on this directory.');
162
+ ui.info(`Concurrency ${config.concurrency}, from this machine (${machine.cpus} logical cpus, ${machine.freeMemGb} GB free).`);
147
163
  if (gitignore === 'added') ui.info(`Added ${GITIGNORE_LINES.join(' and ')} to .gitignore`);
148
164
  if (gitignore === 'absent') ui.warn(`No .gitignore here. Add ${GITIGNORE_LINES.join(' and ')} to whatever ignores build output.`);
149
165
  ui.blank();
@@ -1,12 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
4
+ import { settleFailureLine } from '../run/settle.js';
4
5
  import { getDeps } from '../run/deps.js';
5
6
  import EXIT from '../run/exit.js';
6
7
  import { createHeartbeat } from '../run/heartbeat.js';
7
8
  import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
8
9
  import { jobDir } from '../run/paths.js';
9
- import { runGameProfile, writeCapabilityReport } from '../run/profile.js';
10
+ import { runGameProfile, writeCapabilityReport, writeProfileFailure } from '../run/profile.js';
10
11
  import * as stateFile from '../run/state.js';
11
12
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
12
13
  import { formatCents, resolveSetup } from './run.js';
@@ -64,6 +65,11 @@ async function profile(flags, injected, deps, log) {
64
65
  const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
65
66
 
66
67
  const modules = ['game_profile'];
68
+ // The profile is a read of the repository: --driver, --godot-project and
69
+ // --godot-path are accepted for symmetry with run and change nothing here.
70
+ if (options.driver || options.godotProject || options.godotPath) {
71
+ log('profile reads the repository and does not launch the game, so --driver, --godot-project and --godot-path are not used here. A persona run (run) is what plays the game.');
72
+ }
67
73
  const pack = await deps.packs.get({ api, gameId, modules });
68
74
  // A profile job is the one shape that does not need a brief, so a missing one
69
75
  // is not an error here. It is still read when there is one: a brief that
@@ -186,7 +192,8 @@ async function profile(flags, injected, deps, log) {
186
192
  actions_taken: turns,
187
193
  quit_reason: 'goal_reached'
188
194
  });
189
- await api.jobs.finish(gameId, jobId, {});
195
+ const closed = await api.jobs.finish(gameId, jobId, {});
196
+ if (closed && closed.refund_limited_note) log(closed.refund_limited_note);
190
197
  journal.finished = true;
191
198
  journal.runs[profileRun.run_id].completed = true;
192
199
  journal.runs[profileRun.run_id].state = 'succeeded';
@@ -201,9 +208,20 @@ async function profile(flags, injected, deps, log) {
201
208
  } catch (error) {
202
209
  heartbeat.stop();
203
210
  const code = exitCodeFor(error);
204
- await safeFail({ api, gameId, jobId, runId: profileRun.run_id, error, log });
205
211
  log(`profile failed: ${String(error && error.message)}`);
206
- return code;
212
+ if (Array.isArray(error && error.rawAnswers) && error.rawAnswers.length > 0) {
213
+ try {
214
+ const saved = await writeProfileFailure(directory, error.rawAnswers, String(error.message));
215
+ log(`The model's answers, secrets redacted, are in ${saved}.`);
216
+ } catch (writeError) {
217
+ log(`the model's answers could not be saved: ${String(writeError && writeError.message)}`);
218
+ }
219
+ }
220
+ const settled = await safeFail({ api, gameId, jobId, runId: profileRun.run_id, error, log });
221
+ if (settled) log(`job ${jobId} ${settled.job ? settled.job.state : 'failed'}: refunded ${formatCents(settled.refunded_cents)}.`);
222
+ if (settled && settled.refund_limited_note) log(settled.refund_limited_note);
223
+ // A failure is never exit 0, whatever the settlement says.
224
+ return code === EXIT.OK ? EXIT.FAILED : code;
207
225
  }
208
226
  }
209
227
 
@@ -223,9 +241,10 @@ async function safeFail({ api, gameId, jobId, runId, error, log }) {
223
241
  log(`the run could not be marked failed: ${String(completeError && completeError.message)}`);
224
242
  }
225
243
  try {
226
- await api.jobs.finish(gameId, jobId, { reason: 'profile failed' });
244
+ return await api.jobs.finish(gameId, jobId, { reason: 'profile failed' });
227
245
  } catch (finishError) {
228
- log(`the job could not be settled now; the hourly sweep will settle it: ${String(finishError && finishError.message)}`);
246
+ log(settleFailureLine(jobId, finishError));
247
+ return null;
229
248
  }
230
249
  }
231
250
 
@@ -0,0 +1,204 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getDeps } from '../run/deps.js';
5
+ import EXIT from '../run/exit.js';
6
+ import { jobsDir, runDir as runDirFor } from '../run/paths.js';
7
+ import { checkoutRef, replayTrace } from '../run/replay.js';
8
+ import { findTraceIn, parseTrace, TRACE_FILE } from '../run/trace.js';
9
+
10
+ /**
11
+ * `ravensight-playtest replay <runId> --ref <branch>`: play a recorded persona
12
+ * run again with no model and report the first place it stops matching.
13
+ *
14
+ * Free and local. The commit is extracted to a temp directory, so the developer's
15
+ * checkout is not touched. When this machine is signed in and the run's job is
16
+ * known, the outcome is recorded on the job, which is where the per-job cap
17
+ * lives; a replay that cannot be recorded still happened and still answers.
18
+ *
19
+ * WHAT IT PROVES. Matching means this seed and these inputs produced the same
20
+ * recorded state at every step, on this machine, on this Godot. It does not prove
21
+ * the game is deterministic in general. A divergence on a changed branch is
22
+ * evidence about the change only if the trace also matches where it was recorded,
23
+ * which is what `--baseline` checks and what the output says when it was not.
24
+ */
25
+
26
+ /** The largest game-state value kept in a recorded divergence. */
27
+ const MAX_DIFF_VALUE_CHARS = 200;
28
+
29
+ /** Where a run's trace lives on this machine, by run id alone. */
30
+ export async function findLocalTrace(repoRoot, runId, jobId = null) {
31
+ if (jobId) return { file: await findTraceIn(runDirFor(repoRoot, jobId, runId)), jobId };
32
+ const jobs = await readdir(jobsDir(repoRoot)).catch(() => []);
33
+ for (const candidate of jobs) {
34
+ const file = await findTraceIn(runDirFor(repoRoot, candidate, runId));
35
+ if (file) return { file, jobId: candidate };
36
+ }
37
+ return { file: null, jobId: null };
38
+ }
39
+
40
+ /**
41
+ * A run's trace text: this machine's copy, else the uploaded one. Null when
42
+ * neither exists, and never throws for an ordinary "not there".
43
+ *
44
+ * @returns {Promise<{text: string, jobId: string|null, source: string}|null>}
45
+ */
46
+ export async function loadTrace({ repoRoot, runId, jobId = null, api = null, gameId = null, fetchImpl = fetch }) {
47
+ const local = await findLocalTrace(repoRoot, runId, jobId);
48
+ if (local.file) return { text: await readFile(local.file, 'utf8'), jobId: local.jobId, source: 'local' };
49
+ if (!api || !gameId || !jobId) return null;
50
+ try {
51
+ const { artifacts = [] } = await api.jobs.artifacts(gameId, jobId);
52
+ const entry = artifacts.find(item => item.run_id === runId && item.path === TRACE_FILE && item.status === 'verified');
53
+ if (!entry) return null;
54
+ const response = await fetchImpl(entry.url);
55
+ if (!response.ok) return null;
56
+ return { text: await response.text(), jobId, source: 'uploaded' };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function boundedValue(value) {
63
+ const text = JSON.stringify(value ?? null);
64
+ return text.length <= MAX_DIFF_VALUE_CHARS ? value ?? null : `${text.slice(0, MAX_DIFF_VALUE_CHARS)}...`;
65
+ }
66
+
67
+ /** A replay result in the shape the server records. Hashes and differences, no state dump. */
68
+ export function replayReport(result, { ref, sha }) {
69
+ const divergence = result.first_divergence;
70
+ return {
71
+ ref: String(ref).slice(0, 200),
72
+ sha,
73
+ matched: result.matched,
74
+ fully_replayed: result.fully_replayed,
75
+ steps_total: result.steps_total,
76
+ steps_compared: result.steps_compared,
77
+ ticks: result.ticks,
78
+ stopped: result.stopped || null,
79
+ first_divergence: divergence ? {
80
+ step: divergence.step,
81
+ ...(Number.isInteger(divergence.frame) ? { frame: divergence.frame } : {}),
82
+ ...(divergence.reason ? { reason: String(divergence.reason).slice(0, 500) } : {}),
83
+ ...(divergence.actual_error ? { actual_error: String(divergence.actual_error).slice(0, 500) } : {}),
84
+ ...(divergence.expected ? { expected_hash: divergence.expected.state_hash } : {}),
85
+ ...(divergence.actual ? { actual_hash: divergence.actual.state_hash } : {}),
86
+ ...(divergence.differing_subtrees ? { differing_subtrees: divergence.differing_subtrees.slice(0, 20).map(name => String(name).slice(0, 200)) } : {}),
87
+ ...(divergence.game_state_diff ? {
88
+ game_state_diff: Object.fromEntries(Object.entries(divergence.game_state_diff).slice(0, 20).map(([key, pair]) => [
89
+ key.slice(0, 80),
90
+ { expected: boundedValue(pair.expected), actual: boundedValue(pair.actual) }
91
+ ]))
92
+ } : {})
93
+ } : null
94
+ };
95
+ }
96
+
97
+ /** One sentence per outcome, in the same words `simulation-replay` uses. */
98
+ export function replaySentence(result) {
99
+ if (result.matched) {
100
+ if (result.fully_replayed && (!(result.steps_compared > 0) || !(result.ticks > 0))) return 'Replay compared nothing: the recorded run took no step the game could be held to, so this says nothing about the game.';
101
+ if (result.fully_replayed) return `Replay matched every recorded state (${result.steps_compared} compared, ${result.ticks} ticks).`;
102
+ if (result.stopped === 'redacted_input') return 'Replay matched the recorded prefix only: the run typed a secret from the local brief here, and the trace does not hold it.';
103
+ return 'Replay matched the recorded prefix only. The trace does not establish a complete playthrough.';
104
+ }
105
+ const divergence = result.first_divergence || {};
106
+ const where = `step ${divergence.step}${Number.isInteger(divergence.frame) ? `, tick ${divergence.frame}` : ''}`;
107
+ if (divergence.reason) return `Replay diverged at ${where}: ${divergence.reason}${divergence.actual_error ? ` (${divergence.actual_error})` : ''}`;
108
+ if (divergence.actual_error) return `Replay diverged at ${where}: ${divergence.actual_error}`;
109
+ const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5)
110
+ .map(([key, pair]) => `${key} was ${JSON.stringify(pair.expected)}, now ${JSON.stringify(pair.actual)}`).join('; ');
111
+ const trees = (divergence.differing_subtrees || []).join(', ');
112
+ return `Replay diverged at ${where}.${diff ? ` ${diff}.` : ''}${trees ? ` Differs under: ${trees}.` : ''}`;
113
+ }
114
+
115
+ /**
116
+ * @param {string} runId
117
+ * @param {Object} flags
118
+ * @param {Object} [injected] test seam: deps, replayTrace, checkoutRef, log, fetch
119
+ * @returns {Promise<number>} an exit code
120
+ */
121
+ export async function replayCommand(runId, flags = {}, injected = {}) {
122
+ const deps = injected.deps || (await getDeps());
123
+ const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
124
+ const play = injected.replayTrace || replayTrace;
125
+ const checkout = injected.checkoutRef || checkoutRef;
126
+ const repoRoot = path.resolve(flags.repoRoot || process.cwd());
127
+
128
+ let context = null;
129
+ try {
130
+ context = await deps.createContext({ apiUrl: flags.apiUrl, repoRoot, requireAuth: false });
131
+ } catch {
132
+ context = null;
133
+ }
134
+ const api = context && context.token ? context.api : null;
135
+ const gameId = flags.game || (context && context.config && context.config.game_id) || null;
136
+
137
+ const trace = await loadTrace({ repoRoot, runId, jobId: flags.job || null, api, gameId, fetchImpl: injected.fetch || fetch });
138
+ if (!trace) {
139
+ log(`There is no trace for ${runId} on this machine${flags.job ? ' or on the server' : ''}. Pass --job <jobId> to read an uploaded one. A run records a trace only on the Godot driver, in lockstep.`);
140
+ return EXIT.ENVIRONMENT;
141
+ }
142
+ let parsed;
143
+ try {
144
+ parsed = parseTrace(trace.text);
145
+ } catch (error) {
146
+ log(String(error.message));
147
+ return EXIT.ENVIRONMENT;
148
+ }
149
+
150
+ const project = path.resolve(repoRoot, flags.godotProject || (context && context.config && context.config.godot_project) || '.');
151
+ const subdir = path.relative(repoRoot, project);
152
+ if (subdir.startsWith('..')) {
153
+ log('The Godot project has to be inside this repository to replay a commit of it.');
154
+ return EXIT.ENVIRONMENT;
155
+ }
156
+
157
+ const replayOn = async ref => {
158
+ const checked = await checkout({ repoRoot, ref, projectSubdir: subdir, godotPath: flags.godotPath });
159
+ try {
160
+ const result = await play({ text: trace.text, projectDir: checked.projectDir, godotPath: flags.godotPath, headless: flags.headless ? true : undefined });
161
+ return { result, sha: checked.sha };
162
+ } finally {
163
+ await checked.cleanup();
164
+ }
165
+ };
166
+
167
+ const ref = flags.ref || 'HEAD';
168
+ let baseline = null;
169
+ try {
170
+ if (flags.baseline) {
171
+ if (!parsed.header.commit) {
172
+ log('This trace does not say which commit it was recorded on, so there is nothing to check it against.');
173
+ } else {
174
+ baseline = await replayOn(parsed.header.commit);
175
+ log(`On the commit it was recorded on (${parsed.header.commit.slice(0, 12)}): ${replaySentence(baseline.result)}`);
176
+ }
177
+ }
178
+ const { result, sha } = await replayOn(ref);
179
+
180
+ let recorded = null;
181
+ if (api && gameId && trace.jobId && flags.record !== false) {
182
+ try {
183
+ recorded = await api.runs.recordReplay(gameId, trace.jobId, runId, replayReport(result, { ref, sha }));
184
+ } catch (error) {
185
+ log(`The replay was not recorded on the job: ${String(error && error.message)}`);
186
+ }
187
+ }
188
+
189
+ if (flags.json) {
190
+ (deps.ui && deps.ui.json ? deps.ui.json : value => console.log(JSON.stringify(value)))({ run_id: runId, ref, sha, ...result, baseline: baseline ? baseline.result : null, recorded });
191
+ } else {
192
+ log(`${ref} (${sha.slice(0, 12)}): ${replaySentence(result)}`);
193
+ if (!result.matched) {
194
+ if (baseline && !baseline.result.matched) log('The trace does not replay on the commit it was recorded on either, so this game is not reproducible on this seed and this divergence says nothing about the change.');
195
+ else if (baseline) log('It matches where it was recorded, so the change is what moved it.');
196
+ else log('Pass --baseline to check the trace against the commit it was recorded on. A game that does not honor its seed diverges everywhere, whatever changed.');
197
+ }
198
+ }
199
+ return result.matched ? EXIT.OK : EXIT.FAILED;
200
+ } catch (error) {
201
+ log(String(error && error.message));
202
+ return EXIT.ENVIRONMENT;
203
+ }
204
+ }
@@ -85,6 +85,13 @@ async function resumeWith(context, jobId, flags, deps) {
85
85
  { hint: 'Run resume with no job id to see what is here.' }
86
86
  );
87
87
  }
88
+ if (state.options && state.options.labs === 'feature') {
89
+ throw new CliError(
90
+ `${jobId} is a Build a feature job, and those cannot be resumed.`,
91
+ 1,
92
+ { hint: 'The hourly sweep settles it and gives back what did not happen. The branch keeps whatever was written.' }
93
+ );
94
+ }
88
95
  const gameId = flags.game || state.game_id;
89
96
  if (!gameId) throw new CliError(`The journal for ${jobId} does not say which game it belongs to.`);
90
97