@quenty/nevermore-cli 4.43.0-canary.783.30683872723.0 → 4.43.1

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 (39) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +10 -0
  3. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.js +101 -37
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/deploy-command/index.d.ts +2 -0
  7. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  8. package/dist/commands/deploy-command/index.js +35 -22
  9. package/dist/commands/deploy-command/index.js.map +1 -1
  10. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  11. package/dist/utils/job-context/base-job-context.js +1 -2
  12. package/dist/utils/job-context/base-job-context.js.map +1 -1
  13. package/dist/utils/open-cloud/open-cloud-client.d.ts +8 -4
  14. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  15. package/dist/utils/open-cloud/open-cloud-client.js +12 -18
  16. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  17. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  18. package/dist/utils/testing/parsers/batch-log-parser.js +15 -3
  19. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  20. package/dist/utils/testing/parsers/batch-log-parser.test.js +30 -0
  21. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  22. package/dist/utils/watch/github-context.test.js +9 -1
  23. package/dist/utils/watch/github-context.test.js.map +1 -1
  24. package/dist/utils/watch/http-watch-registry.js +5 -3
  25. package/dist/utils/watch/http-watch-registry.js.map +1 -1
  26. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -1
  27. package/dist/utils/watch/stream-watch-loop.js +42 -51
  28. package/dist/utils/watch/stream-watch-loop.js.map +1 -1
  29. package/package.json +5 -5
  30. package/src/commands/batch-command/batch-deploy-command.ts +135 -41
  31. package/src/commands/deploy-command/index.ts +41 -24
  32. package/src/utils/job-context/base-job-context.ts +1 -2
  33. package/src/utils/open-cloud/open-cloud-client.ts +12 -19
  34. package/src/utils/testing/parsers/batch-log-parser.test.ts +36 -0
  35. package/src/utils/testing/parsers/batch-log-parser.ts +16 -3
  36. package/src/utils/watch/github-context.test.ts +10 -1
  37. package/src/utils/watch/http-watch-registry.ts +5 -3
  38. package/src/utils/watch/stream-watch-loop.ts +73 -59
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -342,8 +342,10 @@ export class OpenCloudClient {
342
342
 
343
343
  private async _fetchRawLogsAsync(taskPath: string): Promise<string> {
344
344
  const apiKey = await this._resolveApiKeyAsync();
345
+ // Arrival order, deliberately. Do not sort by createTime: every print in a
346
+ // single frame shares one value, so it cannot order them anyway, and the
347
+ // fractional digit count varies, so comparing the strings reorders them.
345
348
  const messages: string[] = [];
346
- const structured: { message: string; createTime: string }[] = [];
347
349
  let pageToken: string | undefined;
348
350
  let pagesFetched = 0;
349
351
  let entriesFetched = 0;
@@ -381,14 +383,7 @@ export class OpenCloudClient {
381
383
 
382
384
  for (const entry of data.luauExecutionSessionTaskLogs ?? []) {
383
385
  if (entry.structuredMessages?.length) {
384
- // Keep createTime alongside the text: the API does not guarantee
385
- // chronological order, and readers downstream assume it.
386
- for (const msg of entry.structuredMessages) {
387
- structured.push({
388
- message: msg.message,
389
- createTime: msg.createTime,
390
- });
391
- }
386
+ messages.push(...entry.structuredMessages.map((msg) => msg.message));
392
387
  } else if (entry.messages?.length) {
393
388
  messages.push(...entry.messages);
394
389
  }
@@ -399,12 +394,6 @@ export class OpenCloudClient {
399
394
  pageToken = data.nextPageToken || undefined;
400
395
  } while (pageToken);
401
396
 
402
- // Out-of-order messages silently destroy marker-delimited parsing: a
403
- // trailing summary that arrives early ends the scan before any of the
404
- // output it summarizes has been read.
405
- structured.sort((a, b) => a.createTime.localeCompare(b.createTime));
406
- messages.push(...structured.map((m) => m.message));
407
-
408
397
  const text = messages.join('\n');
409
398
 
410
399
  // Log volume is the prime suspect when engine output goes missing between
@@ -502,10 +491,14 @@ export class OpenCloudClient {
502
491
 
503
492
  /**
504
493
  * Resolve the newest saved or published version number of a place, via the
505
- * Open Cloud Assets API (`asset:read` scope; `legacy-asset:manage` also
506
- * grants it). The returned number is the same value the Asset Delivery API's
507
- * `/version/{n}` route expects, so it can be written straight into a
508
- * `basePlace.version` pin.
494
+ * Open Cloud Assets API (`asset:read` scope). The returned number is the same
495
+ * value the Asset Delivery API's `/version/{n}` route expects, so it can be
496
+ * written straight into a `basePlace.version` pin.
497
+ *
498
+ * This used to add that `legacy-asset:manage` grants `asset:read` too. They
499
+ * are separate boxes on the credentials page and nothing here ever verified
500
+ * the implication, so the claim is gone rather than restated — it had already
501
+ * been copied into the `nevermore login` instructions as fact once.
509
502
  *
510
503
  * `saved` is the newest version of any kind — Studio saves and publishes
511
504
  * share one increasing version sequence. `published` is the newest version
@@ -99,6 +99,42 @@ describe('parseBatchTestLogs', () => {
99
99
  expect(result?.logs).toContain('PlayerBadgeHelper');
100
100
  });
101
101
 
102
+ it('closes a section whose END was delivered after the summary', () => {
103
+ const logs = [
104
+ '===BATCH_TEST_BEGIN egghunt2026===',
105
+ 'Tests: 1014 passed, 1077 total',
106
+ '===BATCH_TEST_SUMMARY===',
107
+ '[{"slug":"egghunt2026","success":true,"durationMs":110467}]',
108
+ '===BATCH_TEST_END egghunt2026 PASS 110467===',
109
+ ].join('\n');
110
+
111
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
112
+
113
+ expect(result?.success).toBe(true);
114
+ expect(result?.error).toBeUndefined();
115
+ expect(result?.testCounts).toEqual({
116
+ passed: 1014,
117
+ failed: 0,
118
+ total: 1077,
119
+ });
120
+ });
121
+
122
+ it('keeps the summary payload out of the section it interrupts', () => {
123
+ const logs = [
124
+ '===BATCH_TEST_BEGIN egghunt2026===',
125
+ 'Tests: 10 passed, 10 total',
126
+ '===BATCH_TEST_SUMMARY===',
127
+ '[{"slug":"egghunt2026","success":true},' +
128
+ '{"slug":"other","success":false,"error":"boom"}]',
129
+ '===BATCH_TEST_END egghunt2026 PASS 10===',
130
+ ].join('\n');
131
+
132
+ const result = parseBatchTestLogs(logs, SLUG_MAP).get('egghunt2026');
133
+
134
+ expect(result?.logs).not.toContain('boom');
135
+ expect(result?.success).toBe(true);
136
+ });
137
+
102
138
  it('counts tracebacks that jest reports as a clean pass', () => {
103
139
  const logs = buildLogs(
104
140
  [
@@ -70,6 +70,8 @@ export function parseBatchTestLogs(
70
70
  let currentSlug: string | null = null;
71
71
  let currentLines: string[] = [];
72
72
  let summaryLineIndex = -1;
73
+ /** True between the summary marker and its JSON payload. */
74
+ let summaryPayloadPending = false;
73
75
  let beginMarkersSeen = 0;
74
76
  let endMarkersSeen = 0;
75
77
  let strayEndMarkers = 0;
@@ -130,8 +132,19 @@ export function parseBatchTestLogs(
130
132
  }
131
133
 
132
134
  if (trimmed === SUMMARY_MARKER) {
133
- summaryLineIndex = i;
134
- break;
135
+ // Keep scanning: the summary prints last but is not always delivered last,
136
+ // and a section's END can follow it.
137
+ if (summaryLineIndex < 0) {
138
+ summaryLineIndex = i;
139
+ }
140
+ summaryPayloadPending = true;
141
+ continue;
142
+ }
143
+
144
+ // The summary's JSON payload belongs to no section.
145
+ if (summaryPayloadPending && trimmed.trimStart().startsWith('[')) {
146
+ summaryPayloadPending = false;
147
+ continue;
135
148
  }
136
149
 
137
150
  // Accumulate unconditionally: output that precedes the first surviving
@@ -209,7 +222,7 @@ export function parseBatchTestLogs(
209
222
  OutputHelper.warn(
210
223
  summaryLineIndex >= lines.length - 2
211
224
  ? '[batch-log-parser] Summary sits at the end of the log — consistent with the head being dropped by a log size/retention limit.'
212
- : '[batch-log-parser] Summary sits early in the log — consistent with out-of-order messages ending the scan prematurely.'
225
+ : '[batch-log-parser] Summary sits early in the log — consistent with messages delivered out of order.'
213
226
  );
214
227
  }
215
228
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import {
3
3
  describeMissingWatchToken,
4
4
  parseGithubRepository,
@@ -35,6 +35,15 @@ describe('tryResolveGithubDispatchContext token', () => {
35
35
  'git rev-parse': 'main',
36
36
  };
37
37
 
38
+ // Cleared so these exercise the git fallback rather than whatever the host
39
+ // happens to export. Actions sets both, and on a pull request GITHUB_REF_NAME
40
+ // is a merge ref — which resolution rightly refuses, so without this the
41
+ // whole describe passes locally and fails only in CI.
42
+ beforeEach(() => {
43
+ vi.stubEnv('GITHUB_REPOSITORY', '');
44
+ vi.stubEnv('GITHUB_REF_NAME', '');
45
+ });
46
+
38
47
  // Registering ships the token to the watch service, so it has to be one the
39
48
  // user handed over. Nearly every dev machine has a logged-in `gh`, so reading
40
49
  // it by default would turn a local watch into a silent credential send.
@@ -239,9 +239,11 @@ function _describeFailure(
239
239
  case 422: {
240
240
  // 422 is the service's "your config cannot work" answer, and it covers
241
241
  // several unrelated causes now — a missing workflow, an Open Cloud key
242
- // without universe.place:read, a "saved" watch with no key, a baseline in
243
- // the wrong vocabulary. Its own message names which, so the only thing
244
- // worth adding is a remedy that could not already be in it.
242
+ // that cannot read a base place, a "saved" watch with no key, a baseline
243
+ // in the wrong vocabulary. Its own message names which, so the only thing
244
+ // worth adding is a remedy that could not already be in it. The scope
245
+ // itself is deliberately not named here: this file would then be a second
246
+ // place to correct when it is wrong, and it already has been once.
245
247
  const workflows = [
246
248
  ...new Set(
247
249
  request.watches.flatMap((w) =>
@@ -96,42 +96,51 @@ export async function runStreamWatchLoopAsync(
96
96
  process.on('SIGINT', onSigint);
97
97
  }
98
98
 
99
- const byWatchName = new Map(entries.map((e) => [e.watchName, e]));
99
+ /** Everything this loop knows about one watch, in one place. */
100
+ interface WatchState {
101
+ entry: StreamWatchEntry;
102
+ /**
103
+ * What the place has actually been built from here, as an Open Cloud place
104
+ * version. Seeded from the lock and advanced only by a rebuild that
105
+ * succeeded: the service moves its own baseline whether or not anyone was
106
+ * listening, so this is the only durable record of what exists.
107
+ */
108
+ built: number | undefined;
109
+ /**
110
+ * The last service-side token this watch was settled at. Purely an
111
+ * optimization — a reconnect that reports the same token needs no Open
112
+ * Cloud call — and deliberately not advanced when a rebuild fails, so the
113
+ * next `ready` retries it.
114
+ */
115
+ settledAt?: string;
116
+ /**
117
+ * Set once the service has told us it cannot read this source, from either
118
+ * direction: the `ready` snapshot on connect, or a `poll-failed` event as
119
+ * it happens.
120
+ *
121
+ * Both are needed. A connection opened before the service's first failed
122
+ * poll gets a clean snapshot, and the next one is an hour away — so without
123
+ * the live tail a private base place would look fine for an hour before the
124
+ * watch admitted it was doing nothing.
125
+ */
126
+ unreadable: boolean;
127
+ }
100
128
 
101
- /**
102
- * What each place has actually been built from here, as an Open Cloud place
103
- * version. Seeded from the lock and advanced only by a rebuild that
104
- * succeeded: the service moves its own baseline whether or not anyone was
105
- * listening, so this map is the only durable record of what exists.
106
- */
107
- const built = new Map<string, number | undefined>(
108
- entries.map((e) => [e.watchName, e.baseline])
129
+ // One record per watch rather than parallel maps under the same key: every
130
+ // field above is a fact about the same thing, and holding them apart only
131
+ // creates the possibility of disagreeing about which watches exist.
132
+ const watches = new Map<string, WatchState>(
133
+ entries.map((e) => [
134
+ e.watchName,
135
+ { entry: e, built: e.baseline, unreadable: false },
136
+ ])
109
137
  );
110
138
 
111
- /**
112
- * The last service-side token each watch was settled at. Purely an
113
- * optimization — a reconnect that reports the same token needs no Open Cloud
114
- * call — and deliberately not advanced when a rebuild fails, so the next
115
- * `ready` retries it.
116
- */
117
- const settledAt = new Map<string, string>();
118
-
119
139
  let redeploys = 0;
120
140
  let failures = 0;
121
141
  let lastDropReason: string | undefined;
122
142
  let unobservable = false;
123
143
 
124
- /**
125
- * Watches the service has told us it cannot read, from either direction:
126
- * the `ready` snapshot on connect, or a `poll-failed` event as it happens.
127
- *
128
- * Both are needed. A connection opened before the service's first failed
129
- * poll gets a clean snapshot, and the next one is an hour away — so without
130
- * the live tail a private base place would look fine for an hour before the
131
- * watch admitted it was doing nothing.
132
- */
133
- const unreadable = new Set<string>();
134
-
135
144
  /**
136
145
  * Give up the stream once nothing on it can ever fire, so the caller can
137
146
  * poll instead — this machine's credentials reach places the service's
@@ -141,7 +150,8 @@ export async function runStreamWatchLoopAsync(
141
150
  // Counted against every entry this run owns, not against what the last
142
151
  // frame happened to mention: a watch the service has stopped reporting
143
152
  // altogether can no more produce a notification than one it cannot read.
144
- if (unreadable.size < byWatchName.size) {
153
+ const all = [...watches.values()];
154
+ if (all.length === 0 || !all.every((w) => w.unreadable)) {
145
155
  return false;
146
156
  }
147
157
  unobservable = true;
@@ -149,6 +159,16 @@ export async function runStreamWatchLoopAsync(
149
159
  return true;
150
160
  };
151
161
 
162
+ /** Marks a watch unreadable, and says whether that was news worth printing. */
163
+ const markUnreadable = (name: string): boolean => {
164
+ const state = watches.get(name);
165
+ if (state == null || state.unreadable) {
166
+ return false;
167
+ }
168
+ state.unreadable = true;
169
+ return true;
170
+ };
171
+
152
172
  OutputHelper.info(
153
173
  `Watching ${entries.length} base place${
154
174
  entries.length === 1 ? '' : 's'
@@ -172,10 +192,11 @@ export async function runStreamWatchLoopAsync(
172
192
  * what the place is actually at, which is the same number the build records.
173
193
  */
174
194
  const settleAsync = async (
175
- entry: StreamWatchEntry,
195
+ state: WatchState,
176
196
  token: string | null
177
197
  ): Promise<void> => {
178
- if (token != null && settledAt.get(entry.watchName) === token) {
198
+ const entry = state.entry;
199
+ if (token != null && state.settledAt === token) {
179
200
  return;
180
201
  }
181
202
 
@@ -196,17 +217,17 @@ export async function runStreamWatchLoopAsync(
196
217
  return;
197
218
  }
198
219
 
199
- const previous = built.get(entry.watchName);
220
+ const previous = state.built;
200
221
  const settle = () => {
201
222
  if (token != null) {
202
- settledAt.set(entry.watchName, token);
223
+ state.settledAt = token;
203
224
  }
204
225
  };
205
226
 
206
227
  if (previous === undefined) {
207
228
  // Never built here and nothing locked: adopt what is there rather than
208
229
  // reading "unknown -> something" as a change.
209
- built.set(entry.watchName, latest);
230
+ state.built = latest;
210
231
  settle();
211
232
  return;
212
233
  }
@@ -221,7 +242,7 @@ export async function runStreamWatchLoopAsync(
221
242
  try {
222
243
  await redeployAsync(entry, latest);
223
244
  redeploys++;
224
- built.set(entry.watchName, latest);
245
+ state.built = latest;
225
246
  settle();
226
247
  OutputHelper.info(`${entry.label}: rebuilt from v${latest}.`);
227
248
  } catch (err) {
@@ -244,7 +265,7 @@ export async function runStreamWatchLoopAsync(
244
265
  // Every connection starts here, including reconnects. Nothing is
245
266
  // replayed while a client is away, so this is the whole catch-up.
246
267
  onReadyAsync: async (ready: WatchStreamReady) => {
247
- const ours = ready.watches.filter((s) => byWatchName.has(s.name));
268
+ const ours = ready.watches.filter((s) => watches.has(s.name));
248
269
 
249
270
  // A source the service cannot read never fires, and the socket says
250
271
  // nothing about it — the failure is visible only here. Waiting
@@ -268,17 +289,16 @@ export async function runStreamWatchLoopAsync(
268
289
  // the same set means a partial snapshot falls back too, instead of
269
290
  // holding the socket for the ones that remain and silently never
270
291
  // rebuilding the rest.
271
- const missing = [...byWatchName.keys()].filter(
292
+ const missing = [...watches.keys()].filter(
272
293
  (name) => !ready.watches.some((s) => s.name === name)
273
294
  );
274
295
  for (const name of missing) {
275
- if (unreadable.has(name)) {
296
+ if (!markUnreadable(name)) {
276
297
  continue;
277
298
  }
278
- unreadable.add(name);
279
299
  OutputHelper.warn(
280
300
  `The watch monitor no longer holds ${
281
- byWatchName.get(name)!.label
301
+ watches.get(name)!.entry.label
282
302
  } — another checkout has probably re-registered it.`
283
303
  );
284
304
  }
@@ -287,13 +307,12 @@ export async function runStreamWatchLoopAsync(
287
307
  }
288
308
 
289
309
  for (const status of failedToRead) {
290
- if (unreadable.has(status.name)) {
310
+ if (!markUnreadable(status.name)) {
291
311
  continue;
292
312
  }
293
- unreadable.add(status.name);
294
313
  OutputHelper.warn(
295
314
  `The watch service cannot read ${
296
- byWatchName.get(status.name)!.label
315
+ watches.get(status.name)!.entry.label
297
316
  }` + (status.sourceError ? `: ${status.sourceError}` : '.')
298
317
  );
299
318
  }
@@ -302,25 +321,23 @@ export async function runStreamWatchLoopAsync(
302
321
  }
303
322
 
304
323
  for (const status of ours) {
305
- if (!unreadable.has(status.name)) {
306
- await settleAsync(
307
- byWatchName.get(status.name)!,
308
- status.currentVersion
309
- );
324
+ const state = watches.get(status.name)!;
325
+ if (!state.unreadable) {
326
+ await settleAsync(state, status.currentVersion);
310
327
  }
311
328
  }
312
329
  },
313
330
 
314
331
  onNotifyAsync: async (notify: WatchStreamNotify) => {
315
- const entry = byWatchName.get(notify.watchName);
316
- if (!entry) {
332
+ const state = watches.get(notify.watchName);
333
+ if (!state) {
317
334
  OutputHelper.verbose(
318
335
  `Ignoring a notification for "${notify.watchName}", which this ` +
319
336
  'run does not watch.'
320
337
  );
321
338
  return;
322
339
  }
323
- await settleAsync(entry, notify.version);
340
+ await settleAsync(state, notify.version);
324
341
  },
325
342
 
326
343
  onEvent: (message) => {
@@ -334,17 +351,14 @@ export async function runStreamWatchLoopAsync(
334
351
  // is polling something that could have been streamed, which still
335
352
  // works.
336
353
  const name = message.event.watchName;
337
- if (
338
- message.event.type !== 'poll-failed' ||
339
- name == null ||
340
- !byWatchName.has(name) ||
341
- unreadable.has(name)
342
- ) {
354
+ if (message.event.type !== 'poll-failed' || name == null) {
355
+ return;
356
+ }
357
+ if (!markUnreadable(name)) {
343
358
  return;
344
359
  }
345
- unreadable.add(name);
346
360
  OutputHelper.warn(
347
- `The watch service cannot read ${byWatchName.get(name)!.label}: ${
361
+ `The watch service cannot read ${watches.get(name)!.entry.label}: ${
348
362
  message.event.message
349
363
  }`
350
364
  );