@irtio/cli 0.5.0 → 0.5.2

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.
@@ -1,17 +1,34 @@
1
1
  import {
2
- createApiClient,
3
- isLoginRequired
4
- } from "./chunk-I37DLT7K.js";
2
+ readProjectConfig
3
+ } from "./chunk-32QTPKVT.js";
5
4
  import {
5
+ HelpRequested,
6
+ createApiClient,
7
+ helpFor,
8
+ helpRequested,
9
+ isLoginRequired,
6
10
  resolveControlUrlForUser
7
- } from "./chunk-NVUKSP5U.js";
11
+ } from "./chunk-TV66QHFP.js";
8
12
 
9
13
  // src/rollback.ts
10
- import { existsSync } from "fs";
11
- import { readFile } from "fs/promises";
12
- import * as path from "path";
14
+ import "fs";
15
+ import "fs/promises";
16
+ import "path";
13
17
  import pc from "picocolors";
18
+ var USAGE = `usage: irtio rollback <version> [options]
19
+
20
+ Re-promotes deployment <version> and brings rooms that migrated past it back to their
21
+ pre-migration state. This DISCARDS whatever those rooms wrote since their migration.
22
+
23
+ options:
24
+ <version> the deployment version to go back to
25
+ --project <id> project id (default: the project file)
26
+ -c, --config <f> the project file to read (default irtio.json)
27
+ --url <control> control plane (default: your stored login)
28
+ -h, --help print this
29
+ `;
14
30
  function parseRollbackArgs(args) {
31
+ if (helpRequested(args)) throw helpFor(USAGE);
15
32
  const parsed = {};
16
33
  for (let i = 0; i < args.length; i++) {
17
34
  const arg = args[i];
@@ -22,6 +39,14 @@ function parseRollbackArgs(args) {
22
39
  parsed.project = value;
23
40
  break;
24
41
  }
42
+ case "-c":
43
+ case "--config": {
44
+ const value = args[++i];
45
+ if (value === void 0 || value === "")
46
+ throw new Error("irtio rollback: --config needs a value");
47
+ parsed.config = value;
48
+ break;
49
+ }
25
50
  case "--url": {
26
51
  const value = args[++i];
27
52
  if (value === void 0) throw new Error("irtio rollback: --url needs a value");
@@ -75,23 +100,13 @@ async function runRollback(options) {
75
100
  }
76
101
  return result;
77
102
  }
78
- async function readIrtioJsonProject(cwd) {
79
- const file = path.join(cwd, "irtio.json");
80
- if (!existsSync(file)) return void 0;
81
- let parsed;
82
- try {
83
- parsed = JSON.parse(await readFile(file, "utf8"));
84
- } catch {
85
- throw new Error(`irtio rollback: ${file} is not valid JSON`);
86
- }
87
- return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
88
- }
89
103
  async function rollback(args, deps = {}) {
90
104
  const log = deps.log ?? ((line) => console.log(line));
91
105
  const errorLog = deps.errorLog ?? ((line) => console.error(line));
92
106
  try {
93
107
  const parsed = parseRollbackArgs(args);
94
- const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
108
+ const config = await readProjectConfig(process.cwd(), "irtio rollback", parsed.config);
109
+ const project = parsed.project ?? config.project;
95
110
  if (project === void 0) {
96
111
  throw new Error(
97
112
  "irtio rollback: no project id \u2014 pass --project or run this from a project with irtio.json"
@@ -105,6 +120,10 @@ async function rollback(args, deps = {}) {
105
120
  ...deps.client !== void 0 ? { client: deps.client } : {}
106
121
  });
107
122
  } catch (err) {
123
+ if (err instanceof HelpRequested) {
124
+ log(err.usage);
125
+ return;
126
+ }
108
127
  if (isLoginRequired(err)) {
109
128
  errorLog(pc.red("not logged in"));
110
129
  errorLog("run: irtio login");
@@ -116,6 +135,7 @@ async function rollback(args, deps = {}) {
116
135
  }
117
136
  }
118
137
  export {
138
+ USAGE,
119
139
  parseRollbackArgs,
120
140
  rollback,
121
141
  runRollback
@@ -1,17 +1,41 @@
1
1
  import {
2
- createApiClient,
3
- isLoginRequired
4
- } from "./chunk-I37DLT7K.js";
2
+ readProjectConfig
3
+ } from "./chunk-32QTPKVT.js";
5
4
  import {
5
+ HelpRequested,
6
+ createApiClient,
7
+ helpFor,
8
+ helpRequested,
9
+ isLoginRequired,
6
10
  resolveControlUrlForUser
7
- } from "./chunk-NVUKSP5U.js";
11
+ } from "./chunk-TV66QHFP.js";
8
12
 
9
13
  // src/rooms.ts
10
- import { existsSync } from "fs";
11
- import { readFile } from "fs/promises";
12
- import * as path from "path";
14
+ import "fs";
15
+ import "fs/promises";
16
+ import "path";
13
17
  import pc from "picocolors";
18
+ var USAGE = `usage: irtio rooms [options]
19
+ irtio rooms saves <room> [options]
20
+ irtio rooms restore <room> --save <id> [options]
21
+
22
+ Lists a project's rooms. Rows not reported for longer than the control plane's retention window
23
+ (30 days by default) are pruned.
24
+
25
+ subcommands:
26
+ saves <room> list a room's save generations, newest first
27
+ restore <room> --save <id>
28
+ bring a room back from a save. This DISCARDS its current state, and
29
+ stops the tenant if it is live
30
+
31
+ options:
32
+ --project <id> project id (default: the project file)
33
+ -c, --config <f> the project file to read (default irtio.json)
34
+ --url <control> control plane (default: your stored login)
35
+ -h, --help print this
36
+ `;
14
37
  function parseRoomsArgs(args) {
38
+ if (helpRequested(args)) throw helpFor(USAGE);
15
39
  const parsed = { sub: "list" };
16
40
  let rest = args;
17
41
  const first = args[0];
@@ -39,6 +63,14 @@ function parseRoomsArgs(args) {
39
63
  parsed.project = value;
40
64
  break;
41
65
  }
66
+ case "-c":
67
+ case "--config": {
68
+ const value = args[++i];
69
+ if (value === void 0 || value === "")
70
+ throw new Error("irtio rooms: --config needs a value");
71
+ parsed.config = value;
72
+ break;
73
+ }
42
74
  case "--url": {
43
75
  const value = rest[++i];
44
76
  if (value === void 0) throw new Error("irtio rooms: --url needs a value");
@@ -140,23 +172,13 @@ async function runRooms(options) {
140
172
  for (const line of formatTable(rows)) log(line);
141
173
  return rows;
142
174
  }
143
- async function readIrtioJsonProject(cwd) {
144
- const file = path.join(cwd, "irtio.json");
145
- if (!existsSync(file)) return void 0;
146
- let parsed;
147
- try {
148
- parsed = JSON.parse(await readFile(file, "utf8"));
149
- } catch {
150
- throw new Error(`irtio rooms: ${file} is not valid JSON`);
151
- }
152
- return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
153
- }
154
175
  async function rooms(args, deps = {}) {
155
176
  const log = deps.log ?? ((line) => console.log(line));
156
177
  const errorLog = deps.errorLog ?? ((line) => console.error(line));
157
178
  try {
158
179
  const parsed = parseRoomsArgs(args);
159
- const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
180
+ const config = await readProjectConfig(process.cwd(), "irtio rooms", parsed.config);
181
+ const project = parsed.project ?? config.project;
160
182
  if (project === void 0) {
161
183
  throw new Error(
162
184
  "irtio rooms: no project id \u2014 pass --project or run this from a project with irtio.json"
@@ -180,6 +202,10 @@ async function rooms(args, deps = {}) {
180
202
  await runRooms(common);
181
203
  }
182
204
  } catch (err) {
205
+ if (err instanceof HelpRequested) {
206
+ log(err.usage);
207
+ return;
208
+ }
183
209
  if (isLoginRequired(err)) {
184
210
  errorLog(pc.red("not logged in"));
185
211
  errorLog("run: irtio login");
@@ -191,6 +217,7 @@ async function rooms(args, deps = {}) {
191
217
  }
192
218
  }
193
219
  export {
220
+ USAGE,
194
221
  parseRoomsArgs,
195
222
  rooms,
196
223
  runRestore,
@@ -1,4 +1,4 @@
1
- import { SimulationReport } from '@irtio/bots';
1
+ import { RunEnd, SimulationReport, TickHealthReading } from '@irtio/bots';
2
2
  import { AnySchema } from '@irtio/schema';
3
3
 
4
4
  /**
@@ -26,7 +26,9 @@ interface SimulateArgs {
26
26
  mispredictionMax?: number;
27
27
  snapsMax?: number;
28
28
  correctionsMax?: number;
29
+ overrunsMax?: number;
29
30
  }
31
+ declare const USAGE = "usage: irtio simulate [options]\n\nDrives N real clients at a running room and checks the built-in invariants. Run it from a project\ndirectory so it can load irtio/schema.ts; without one it falls back to a relay run.\n\noptions:\n --bots <n> how many clients to spawn (default 5)\n --seconds <n> how long to play for (default 10)\n --room <code> join this room instead of creating one\n --url <ws://...> where to connect (default ws://localhost:7070)\n --key <projectKey> the project key to present\n --trace <path> write the frame trace here\n --cheat send illegal writes and expect corrections\n --misprediction-max <units> fail if one correction snaps a prediction further than this\n --snaps-max <n> fail above this many cap-exceeded reconciliations\n --corrections-max <perSec> fail above this correction rate per bot\n --overruns-max <n> fail above this many server tick overruns in the run window\n (default 0; the count is read from the server, and the run says\n so when it could not be read)\n -h, --help print this\n\nexit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be\nperformed (nobody joined, or it passed its wall-clock ceiling).\n";
30
32
  /** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
31
33
  declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
32
34
  interface LoadSchemaOptions {
@@ -70,6 +72,41 @@ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWor
70
72
  * description, or `undefined` when the worlds agree.
71
73
  */
72
74
  declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
75
+ /**
76
+ * The dev server's JSON view, which is where a local run reads the server's own tick counters.
77
+ * `irtio dev` serves it on the socket's own port; nothing else does, and that is deliberate — a
78
+ * deployed tenant's counters belong to the control plane, not to whoever can reach the socket.
79
+ */
80
+ declare function stateUrlFor(wsUrl: string): string | undefined;
81
+ interface RoomTickCounters {
82
+ readonly overruns?: number;
83
+ readonly maxTickMs?: number;
84
+ }
85
+ /**
86
+ * Reads one room's tick counters off the dev server, or explains why it could not. Every failure
87
+ * path returns a sentence rather than a zero: the `tick-health` invariant has an `unavailable`
88
+ * state precisely so that "the room dropped no ticks" and "nobody asked the room" stay different
89
+ * answers.
90
+ */
91
+ declare function readTickCounters(wsUrl: string, roomId: string): Promise<{
92
+ counters?: RoomTickCounters;
93
+ unavailable?: string;
94
+ source?: string;
95
+ }>;
96
+ /**
97
+ * The run-window reading: overruns are a lifetime counter on the server, so the window is the
98
+ * difference between a baseline taken as the bots came up and a reading taken as they went down.
99
+ * `maxTickMs` has no windowed form (it is a running maximum) and is reported as what it is.
100
+ */
101
+ declare function tickHealthFrom(before: {
102
+ counters?: RoomTickCounters;
103
+ unavailable?: string;
104
+ source?: string;
105
+ }, after: {
106
+ counters?: RoomTickCounters;
107
+ unavailable?: string;
108
+ source?: string;
109
+ }): TickHealthReading;
73
110
  interface RunSimulationOptions extends Partial<SimulateArgs> {
74
111
  /** Project root; defaults to `process.cwd()`. */
75
112
  readonly cwd?: string;
@@ -80,9 +117,35 @@ interface RunSimulationOptions extends Partial<SimulateArgs> {
80
117
  * hardware can burst past the cursor-tuned default without anything being wrong.
81
118
  */
82
119
  readonly correctionsPerSecMax?: number;
120
+ /** D36: initial-join bound, passed through to `spawnBots`. */
121
+ readonly joinTimeoutMs?: number;
122
+ /** D36: room-gone grace, passed through to `spawnBots`. */
123
+ readonly roomGoneGraceMs?: number;
124
+ /**
125
+ * D36: the hard wall-clock ceiling on the whole run. Default `seconds × 3 + 30 s`. Reaching it
126
+ * is a fatal run condition, not a clean end.
127
+ */
128
+ readonly ceilingMs?: number;
83
129
  /** @internal Test seam, same as `startDev`'s. */
84
130
  readonly irtioPackages?: Record<string, string> | undefined;
85
131
  }
132
+ /** D36's wall-clock ceiling: generous enough that no healthy run meets it. */
133
+ declare function ceilingFor(seconds: number): number;
134
+ /**
135
+ * D36: the run could not be performed at all — nobody joined, so there is no report to print and
136
+ * nothing was measured. `irtio simulate` turns this into exit code 2, which is the whole point of
137
+ * having a third code: "clean" and "violation" are both claims about a room, and this is not one.
138
+ */
139
+ declare class RunNotPerformedError extends Error {
140
+ readonly cause?: unknown | undefined;
141
+ readonly name = "RunNotPerformedError";
142
+ constructor(message: string, cause?: unknown | undefined);
143
+ }
144
+ /**
145
+ * D36: what ended the run. `scripts`/`duration` are the ordinary ends; `room-gone` and `ceiling`
146
+ * are the two fatal run conditions, and both still print the report and write the trace.
147
+ */
148
+ type SimulationEnd = RunEnd | 'ceiling';
86
149
  interface SimulationRun {
87
150
  readonly report: SimulationReport;
88
151
  readonly tracePath: string;
@@ -93,6 +156,12 @@ interface SimulationRun {
93
156
  /** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
94
157
  * (or did not apply). A failure also fails the run. */
95
158
  readonly worldError?: string;
159
+ /** D36: what ended the run. */
160
+ readonly endedBy: SimulationEnd;
161
+ /** D36: the fatal run condition in one sentence, when one ended the run. */
162
+ readonly fatal?: string;
163
+ /** D36: `0` clean, `1` a violation was measured, `2` the run was not performed as asked. */
164
+ readonly exitCode: 0 | 1 | 2;
96
165
  /** Everything that was printed, in order — the same lines `log` received. */
97
166
  readonly output: readonly string[];
98
167
  }
@@ -100,4 +169,4 @@ interface SimulationRun {
100
169
  declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
101
170
  declare function simulate(args: readonly string[]): Promise<void>;
102
171
 
103
- export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, checkWorldDeterminism, loadProjectSchema, loadProjectWorld, parseSimulateArgs, runSimulation, simulate };
172
+ export { type LoadSchemaOptions, RunNotPerformedError, type RunSimulationOptions, type SimulateArgs, type SimulationEnd, type SimulationRun, USAGE, ceilingFor, checkWorldDeterminism, loadProjectSchema, loadProjectWorld, parseSimulateArgs, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
package/dist/simulate.js CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ HelpRequested,
3
+ helpFor,
4
+ helpRequested,
5
+ trailerFor
6
+ } from "./chunk-TV66QHFP.js";
7
+
1
8
  // src/simulate.ts
2
9
  import { existsSync } from "fs";
3
10
  import { mkdir } from "fs/promises";
@@ -25,7 +32,32 @@ function nonNegativeInt(raw, flag) {
25
32
  }
26
33
  return value;
27
34
  }
35
+ var USAGE = `usage: irtio simulate [options]
36
+
37
+ Drives N real clients at a running room and checks the built-in invariants. Run it from a project
38
+ directory so it can load irtio/schema.ts; without one it falls back to a relay run.
39
+
40
+ options:
41
+ --bots <n> how many clients to spawn (default ${DEFAULT_BOTS})
42
+ --seconds <n> how long to play for (default ${DEFAULT_SECONDS})
43
+ --room <code> join this room instead of creating one
44
+ --url <ws://...> where to connect (default ${DEFAULT_URL})
45
+ --key <projectKey> the project key to present
46
+ --trace <path> write the frame trace here
47
+ --cheat send illegal writes and expect corrections
48
+ --misprediction-max <units> fail if one correction snaps a prediction further than this
49
+ --snaps-max <n> fail above this many cap-exceeded reconciliations
50
+ --corrections-max <perSec> fail above this correction rate per bot
51
+ --overruns-max <n> fail above this many server tick overruns in the run window
52
+ (default 0; the count is read from the server, and the run says
53
+ so when it could not be read)
54
+ -h, --help print this
55
+
56
+ exit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be
57
+ performed (nobody joined, or it passed its wall-clock ceiling).
58
+ `;
28
59
  function parseSimulateArgs(args) {
60
+ if (helpRequested(args)) throw helpFor(USAGE);
29
61
  const parsed = { bots: DEFAULT_BOTS, seconds: DEFAULT_SECONDS, cheat: false };
30
62
  for (let i = 0; i < args.length; i++) {
31
63
  const arg = args[i];
@@ -72,6 +104,9 @@ function parseSimulateArgs(args) {
72
104
  case "--corrections-max":
73
105
  parsed.correctionsMax = positive(value(), "--corrections-max");
74
106
  break;
107
+ case "--overruns-max":
108
+ parsed.overrunsMax = nonNegativeInt(value(), "--overruns-max");
109
+ break;
75
110
  default:
76
111
  throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
77
112
  }
@@ -178,13 +213,91 @@ function everyByteEqual(a, b) {
178
213
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
179
214
  return true;
180
215
  }
216
+ var TICK_HEALTH_TIMEOUT_MS = 2e3;
217
+ function stateUrlFor(wsUrl) {
218
+ let parsed;
219
+ try {
220
+ parsed = new URL(wsUrl);
221
+ } catch {
222
+ return void 0;
223
+ }
224
+ if (parsed.protocol === "ws:") parsed.protocol = "http:";
225
+ else if (parsed.protocol === "wss:") parsed.protocol = "https:";
226
+ else if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
227
+ parsed.pathname = "/__irt/state.json";
228
+ parsed.search = "";
229
+ return parsed.toString();
230
+ }
231
+ async function readTickCounters(wsUrl, roomId) {
232
+ const stateUrl = stateUrlFor(wsUrl);
233
+ if (stateUrl === void 0) {
234
+ return { unavailable: `cannot derive an inspector URL from ${wsUrl}` };
235
+ }
236
+ let body;
237
+ try {
238
+ const response = await fetch(stateUrl, {
239
+ signal: AbortSignal.timeout(TICK_HEALTH_TIMEOUT_MS)
240
+ });
241
+ if (!response.ok) {
242
+ return {
243
+ unavailable: `${stateUrl} answered ${response.status}, so the server's tick counters were not read` + (response.status === 404 ? ". Only `irtio dev` serves the inspector; a deployed tenant reports ticks through `irtio rooms` instead." : "")
244
+ };
245
+ }
246
+ body = await response.json();
247
+ } catch (err) {
248
+ return {
249
+ unavailable: `could not reach ${stateUrl} (${err instanceof Error ? err.message : String(err)})`
250
+ };
251
+ }
252
+ const rooms = body?.rooms;
253
+ const room = Array.isArray(rooms) ? rooms.find((r) => r?.id === roomId) : void 0;
254
+ if (room === void 0) return { unavailable: `${stateUrl} did not report room ${roomId}` };
255
+ const overruns = room.metrics?.overruns;
256
+ if (typeof overruns !== "number") {
257
+ return {
258
+ unavailable: `${stateUrl} reported room ${roomId} without tick counters (no live worker)`,
259
+ source: stateUrl
260
+ };
261
+ }
262
+ return {
263
+ counters: {
264
+ overruns,
265
+ ...typeof room.metrics?.maxTickMs === "number" ? { maxTickMs: room.metrics.maxTickMs } : {}
266
+ },
267
+ source: stateUrl
268
+ };
269
+ }
270
+ function tickHealthFrom(before, after) {
271
+ if (after.unavailable !== void 0 || after.counters?.overruns === void 0) {
272
+ return { unavailable: after.unavailable ?? "no overrun count came back from the server" };
273
+ }
274
+ const baseline = before.counters?.overruns ?? 0;
275
+ const overruns = Math.max(0, after.counters.overruns - baseline);
276
+ return {
277
+ overruns,
278
+ ...after.counters.maxTickMs !== void 0 ? { maxTickMs: after.counters.maxTickMs } : {},
279
+ ...after.source !== void 0 ? { source: after.source } : {}
280
+ };
281
+ }
282
+ var STOP_GRACE_MS = 5e3;
283
+ function ceilingFor(seconds) {
284
+ return seconds * 3e3 + 3e4;
285
+ }
286
+ var RunNotPerformedError = class extends Error {
287
+ constructor(message, cause) {
288
+ super(message);
289
+ this.cause = cause;
290
+ }
291
+ cause;
292
+ name = "RunNotPerformedError";
293
+ };
181
294
  function rate(bytesPerSecond) {
182
295
  return bytesPerSecond >= 1e3 ? `${(bytesPerSecond / 1e3).toFixed(1)} kB/s` : `${Math.round(bytesPerSecond)} B/s`;
183
296
  }
184
297
  function formatReport(report, schemaLoaded, cheating) {
185
298
  const lines = [];
186
299
  for (const invariant of report.invariants) {
187
- const mark = invariant.ok ? pc.green("ok ") : pc.red("FAIL");
300
+ const mark = invariant.state === "unavailable" ? pc.yellow("n/a ") : invariant.ok ? pc.green("ok ") : pc.red("FAIL");
188
301
  lines.push(` ${mark} ${invariant.name.padEnd(17)} ${pc.dim(invariant.detail)}`);
189
302
  }
190
303
  lines.push("");
@@ -199,8 +312,13 @@ function formatReport(report, schemaLoaded, cheating) {
199
312
  if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
200
313
  lines.push("");
201
314
  const failed = report.invariants.filter((i) => !i.ok);
315
+ const unread = report.invariants.filter((i) => i.state === "unavailable");
202
316
  if (failed.length === 0) {
203
- lines.push(pc.green(` every invariant held across ${report.bots} bots.`));
317
+ lines.push(
318
+ pc.green(
319
+ ` every invariant held across ${report.bots} bots` + (unread.length === 0 ? "." : `, except ${unread.map((i) => i.name).join(", ")}, which could not be read.`)
320
+ )
321
+ );
204
322
  } else {
205
323
  lines.push(
206
324
  pc.red(` ${failed.length} invariant(s) failed: ${failed.map((i) => i.name).join(", ")}`)
@@ -253,30 +371,67 @@ async function runSimulation(options = {}) {
253
371
  ...options.room !== void 0 ? { room: options.room } : {},
254
372
  ...options.correctionsPerSecMax !== void 0 ? { correctionsPerSecMax: options.correctionsPerSecMax } : options.correctionsMax !== void 0 ? { correctionsPerSecMax: options.correctionsMax } : {},
255
373
  ...options.mispredictionMax !== void 0 ? { mispredictionMagnitudeMax: options.mispredictionMax } : {},
256
- ...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {}
374
+ ...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {},
375
+ ...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {}
257
376
  };
258
377
  const predicted = world !== void 0 && worldError === void 0;
259
- const runner = loaded ? await spawnBots(bots, {
378
+ const spawn = {
260
379
  ...common,
261
- schema: loaded.schema,
262
- script: randomScript(loaded.schema, { cheat: options.cheat ?? false }),
263
- ...predicted ? {
264
- physics: {
265
- gravity: world.gravity,
266
- ...world.timestep !== void 0 ? { timestep: world.timestep } : {},
267
- ...world.setup !== void 0 ? { setup: world.setup } : {},
268
- ...world.bodies !== void 0 ? { bodies: world.bodies } : {},
269
- ...world.intents !== void 0 ? { intents: world.intents } : {}
270
- }
271
- } : {}
272
- }) : await spawnBots(bots, { ...common, script: relayEchoScript() });
380
+ ...options.joinTimeoutMs !== void 0 ? { joinTimeoutMs: options.joinTimeoutMs } : {},
381
+ ...options.roomGoneGraceMs !== void 0 ? { roomGoneGraceMs: options.roomGoneGraceMs } : {}
382
+ };
383
+ let runner;
384
+ try {
385
+ runner = loaded ? await spawnBots(bots, {
386
+ ...spawn,
387
+ schema: loaded.schema,
388
+ script: randomScript(loaded.schema, { cheat: options.cheat ?? false }),
389
+ ...predicted ? {
390
+ physics: {
391
+ gravity: world.gravity,
392
+ ...world.timestep !== void 0 ? { timestep: world.timestep } : {},
393
+ ...world.setup !== void 0 ? { setup: world.setup } : {},
394
+ ...world.bodies !== void 0 ? { bodies: world.bodies } : {},
395
+ ...world.intents !== void 0 ? { intents: world.intents } : {}
396
+ }
397
+ } : {}
398
+ }) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
399
+ } catch (err) {
400
+ throw new RunNotPerformedError(
401
+ `irtio simulate: no bot ever joined ${url}, so nothing was measured.
402
+ ` + (err instanceof Error ? err.message : String(err)),
403
+ err
404
+ );
405
+ }
273
406
  log("");
274
407
  log(
275
408
  `${pc.bold("irtio simulate")} \u2014 ${bots} bot${bots === 1 ? "" : "s"} \xD7 ${seconds}s on ${url} (room ${pc.bold(runner.roomId)})${options.cheat ? pc.yellow(" [cheat]") : ""}`
276
409
  );
277
410
  log("");
278
- await runner.done();
279
- const stopped = await runner.stop();
411
+ const tickBefore = await readTickCounters(url, runner.roomId);
412
+ const ceilingMs = options.ceilingMs ?? ceilingFor(seconds);
413
+ let ceilingHit = false;
414
+ const ceiling = new Promise((resolve2) => {
415
+ const timer = setTimeout(() => {
416
+ ceilingHit = true;
417
+ resolve2();
418
+ }, ceilingMs);
419
+ void runner.done().catch(() => void 0).finally(() => {
420
+ clearTimeout(timer);
421
+ resolve2();
422
+ });
423
+ });
424
+ await ceiling;
425
+ runner.recordTickHealth(tickHealthFrom(tickBefore, await readTickCounters(url, runner.roomId)));
426
+ const stopped = await Promise.race([
427
+ runner.stop(),
428
+ new Promise((resolve2) => {
429
+ const timer = setTimeout(() => resolve2(runner.report()), STOP_GRACE_MS);
430
+ timer.unref?.();
431
+ })
432
+ ]);
433
+ const endedBy = ceilingHit ? "ceiling" : runner.endedBy ?? "scripts";
434
+ const fatal = endedBy === "ceiling" ? `the run passed its wall-clock ceiling of ${Math.round(ceilingMs / 1e3)}s without ending on its own, so it was cut short here` : endedBy === "room-gone" ? "every bot was disconnected at once and none rejoined, so the room is gone" : void 0;
280
435
  await mkdir(outDir, { recursive: true });
281
436
  const tracePath = path.resolve(
282
437
  cwd,
@@ -307,12 +462,17 @@ async function runSimulation(options = {}) {
307
462
  for (const failure of runner.scriptErrors) {
308
463
  log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
309
464
  }
465
+ if (fatal !== void 0) log(pc.red(` run ended early: ${fatal}`));
466
+ const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0 ? 1 : 0;
310
467
  return {
311
468
  report,
312
469
  tracePath,
313
470
  schema: loaded !== void 0,
314
471
  predicted,
315
472
  ...worldError !== void 0 ? { worldError } : {},
473
+ endedBy,
474
+ ...fatal !== void 0 ? { fatal } : {},
475
+ exitCode,
316
476
  output
317
477
  };
318
478
  }
@@ -321,24 +481,38 @@ async function simulate(args) {
321
481
  try {
322
482
  parsed = parseSimulateArgs(args);
323
483
  } catch (err) {
484
+ if (err instanceof HelpRequested) {
485
+ console.log(err.usage);
486
+ return;
487
+ }
324
488
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
325
489
  process.exitCode = 1;
326
490
  return;
327
491
  }
328
492
  try {
329
493
  const run = await runSimulation(parsed);
330
- if (!run.report.ok || run.worldError !== void 0) process.exitCode = 1;
494
+ if (run.exitCode !== 0) process.exitCode = run.exitCode;
331
495
  } catch (err) {
332
496
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
333
- console.error(pc.dim("is `irtio dev` running? pass --url to point somewhere else."));
334
- process.exitCode = 1;
497
+ const localDefault = parsed.url === void 0 || parsed.url === DEFAULT_URL;
498
+ const trailer = trailerFor(err, [
499
+ ...err instanceof RunNotPerformedError && localDefault ? [`nothing is answering ${DEFAULT_URL}. Start the room with \`irtio dev\`, or pass --url.`] : []
500
+ ]);
501
+ if (trailer !== void 0) console.error(pc.dim(trailer));
502
+ process.exitCode = err instanceof RunNotPerformedError ? 2 : 1;
335
503
  }
336
504
  }
337
505
  export {
506
+ RunNotPerformedError,
507
+ USAGE,
508
+ ceilingFor,
338
509
  checkWorldDeterminism,
339
510
  loadProjectSchema,
340
511
  loadProjectWorld,
341
512
  parseSimulateArgs,
513
+ readTickCounters,
342
514
  runSimulation,
343
- simulate
515
+ simulate,
516
+ stateUrlFor,
517
+ tickHealthFrom
344
518
  };
@@ -0,0 +1,17 @@
1
+ import {
2
+ USAGE,
3
+ parseStaticDeployArgs,
4
+ runStaticDeploy,
5
+ staticDeploy,
6
+ walkStaticDir
7
+ } from "./chunk-GBNHBWES.js";
8
+ import "./chunk-BPE452KF.js";
9
+ import "./chunk-32QTPKVT.js";
10
+ import "./chunk-TV66QHFP.js";
11
+ export {
12
+ USAGE,
13
+ parseStaticDeployArgs,
14
+ runStaticDeploy,
15
+ staticDeploy,
16
+ walkStaticDir
17
+ };