@oar-sh/oar 0.9.12 → 0.9.13
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/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -32,6 +32,8 @@ export const TASK_OUTPUT_LIMITS = Object.freeze({
|
|
|
32
32
|
healthBytes: 64 * 1024,
|
|
33
33
|
lineCharsMax: 4000,
|
|
34
34
|
outputPathMax: 1024,
|
|
35
|
+
// A shell task's command line on its row.
|
|
36
|
+
commandCharsMax: 2000,
|
|
35
37
|
// Ended rows: kept this long, at most this many per conversation.
|
|
36
38
|
endedKeepMs: 60 * 60 * 1000,
|
|
37
39
|
endedMax: 20,
|
|
@@ -269,7 +271,7 @@ function clampByte(value) {
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
function plainStyle() {
|
|
272
|
-
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
|
|
274
|
+
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false };
|
|
273
275
|
}
|
|
274
276
|
|
|
275
277
|
function applySgr(state, params) {
|
|
@@ -281,11 +283,13 @@ function applySgr(state, params) {
|
|
|
281
283
|
else if (code === 2) state.dim = true;
|
|
282
284
|
else if (code === 3) state.italic = true;
|
|
283
285
|
else if (code === 4) state.underline = true;
|
|
286
|
+
else if (code === 7) state.inverse = true;
|
|
284
287
|
else if (code === 22) {
|
|
285
288
|
state.bold = false;
|
|
286
289
|
state.dim = false;
|
|
287
290
|
} else if (code === 23) state.italic = false;
|
|
288
291
|
else if (code === 24) state.underline = false;
|
|
292
|
+
else if (code === 27) state.inverse = false;
|
|
289
293
|
else if (code >= 30 && code <= 37) state.fg = code - 30;
|
|
290
294
|
else if (code === 39) state.fg = null;
|
|
291
295
|
else if (code >= 40 && code <= 47) state.bg = code - 40;
|
|
@@ -310,8 +314,11 @@ function applySgr(state, params) {
|
|
|
310
314
|
/**
|
|
311
315
|
* One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
|
|
312
316
|
* colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
|
|
313
|
-
*
|
|
314
|
-
*
|
|
317
|
+
* Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
|
|
318
|
+
* and `inverse: true`: a null `fg` then means the default background as the
|
|
319
|
+
* text colour, a null `bg` the default text colour as the background (the
|
|
320
|
+
* filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
|
|
321
|
+
* style. Any escape that is not SGR is dropped from the text.
|
|
315
322
|
*/
|
|
316
323
|
export function ansiToSegments(lineText) {
|
|
317
324
|
const text = String(lineText ?? '');
|
|
@@ -320,13 +327,17 @@ export function ansiToSegments(lineText) {
|
|
|
320
327
|
const pushText = (value) => {
|
|
321
328
|
const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
|
|
322
329
|
if (!clean) return;
|
|
330
|
+
const fg = state.inverse ? state.bg : state.fg;
|
|
331
|
+
const bg = state.inverse ? state.fg : state.bg;
|
|
323
332
|
const last = segments[segments.length - 1];
|
|
324
|
-
if (last && last.fg ===
|
|
325
|
-
&& last.
|
|
333
|
+
if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
|
|
334
|
+
&& last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
|
|
326
335
|
last.text += clean;
|
|
327
336
|
return;
|
|
328
337
|
}
|
|
329
|
-
|
|
338
|
+
const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
|
|
339
|
+
if (state.inverse) segment.inverse = true;
|
|
340
|
+
segments.push(segment);
|
|
330
341
|
};
|
|
331
342
|
let at = 0;
|
|
332
343
|
const pattern = new RegExp(SGR_PATTERN.source, 'g');
|
|
@@ -389,15 +400,38 @@ const KNOWN_TOKEN_RULES = Object.freeze([
|
|
|
389
400
|
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, '$1 [redacted]'],
|
|
390
401
|
// A password in a URL: scheme://user:password@host.
|
|
391
402
|
[/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[redacted]@'],
|
|
403
|
+
// A Slack incoming webhook: the path is the credential.
|
|
404
|
+
[/\b(hooks\.slack\.com\/services\/)[A-Za-z0-9_/-]+/g, '$1[redacted]'],
|
|
405
|
+
// Credentials in a query string: ?key=…, &sig=…, &token=…
|
|
406
|
+
[/([?&](?:key|api_?key|sig|signature|token|access_token|auth|password|secret)=)(?!\[redacted\])[^&\s#"'`]+/gi, '$1[redacted]'],
|
|
407
|
+
// An `Authorization:` header, any case (`AUTHORIZATION: basic …` in a git
|
|
408
|
+
// http.extraheader), any scheme word (`token <hex>`, `Bot …`): the word
|
|
409
|
+
// stays, the value goes. Bearer/Basic outside a header stay case-exact
|
|
410
|
+
// above, so prose ("basic functionality") is left alone.
|
|
411
|
+
[/\b(authorization["']?\s*[:=]\s*["']?(?:token|bot|digest|apikey|bearer|basic)\s+)(?!\[redacted\])[^\s"',;]+/gi, '$1[redacted]'],
|
|
412
|
+
// curl -u / --user name:password, also attached (-ualice:pw). Only on a
|
|
413
|
+
// curl line: `docker run -u node:node`, `ps -u alice:staff` are no secret.
|
|
414
|
+
[/\b(curl\b[^\n|;&]*?\s(?:-u|--user)(?:\s+|=)?["']?[^\s:"']+:)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
|
|
415
|
+
// A secret flag's value: --password x, --token=x, --secret x, --api-key x.
|
|
416
|
+
[/((?:^|\s)--(?:password|passwd|token|secret|api-key|access-token|client-secret)(?:\s+|=)["']?)(?!\[redacted\]|\$|-)[^\s"']+/gi, '$1[redacted]'],
|
|
417
|
+
// mysql -p<password> (attached), docker login -p <password>.
|
|
418
|
+
[/\b(mysql\w*\b[^\n|;&]*?\s-p)(?!\[redacted\])([^\s"']+)/g, '$1[redacted]'],
|
|
419
|
+
[/\b(docker\s+login\b[^\n|;&]*?\s-p\s+)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
|
|
420
|
+
// sshpass -p <password> (or attached), redis-cli -a <password>.
|
|
421
|
+
[/\b(sshpass\b[^\n|;&]*?\s-p\s*)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
|
|
422
|
+
[/\b(redis-cli\b[^\n|;&]*?\s-a\s+)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
|
|
423
|
+
// An AWS credentials file or ini line (aws_secret_access_key = …), or
|
|
424
|
+
// `aws configure set aws_secret_access_key <value>`.
|
|
425
|
+
[/\b(aws_secret_access_key|aws_session_token)(\s*[:=]\s*["']?|\s+)(?!\[redacted\]|\$)[^\s"',;]{4,}/gi, '$1$2[redacted]'],
|
|
392
426
|
]);
|
|
393
427
|
|
|
394
428
|
// NAME=value with a secret-sounding environment name (API_TOKEN=…,
|
|
395
429
|
// DB_PASSWORD="…"); the name stays. A value without a letter (a count, a
|
|
396
430
|
// port) or a placeholder (true, null, ***) is no secret.
|
|
397
|
-
const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b
|
|
431
|
+
const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b|\*+|\$)(?=[^\s"'`]*[A-Za-z])([^\s"'`]{4,})/gi;
|
|
398
432
|
// `token: value`, `"password": "value"`, `api_key = value`, Authorization: …
|
|
399
433
|
// A Bearer/Basic value was handled above and keeps its scheme word.
|
|
400
|
-
const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b
|
|
434
|
+
const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic|token|bot|digest|apikey)\s|(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b|\*+|\$)(?=[^\s"',;]*[A-Za-z])([^\s"',;]{4,})/gi;
|
|
401
435
|
// Anything else token-shaped: 32 or more of [A-Za-z0-9_-] with an upper-case
|
|
402
436
|
// letter, a lower-case letter and a digit. Random keys have all three; test
|
|
403
437
|
// names, paths and hex commit ids rarely do.
|
|
@@ -433,6 +467,25 @@ export function cleanTaskOutput(text) {
|
|
|
433
467
|
return scrubTaskOutput(stripControlKeepSgr(text));
|
|
434
468
|
}
|
|
435
469
|
|
|
470
|
+
/**
|
|
471
|
+
* A worker's text about a task as the relay keeps it on the row (its
|
|
472
|
+
* description, summary, last tool call, command line): no colour or control
|
|
473
|
+
* codes, credentials scrubbed as in the output (before the cut, so a cut
|
|
474
|
+
* never leaves half a secret unrecognised), at most `max` characters. ''
|
|
475
|
+
* when there is none. A description can be the command line itself (Grok's
|
|
476
|
+
* terminals, a Copilot shell without one).
|
|
477
|
+
*/
|
|
478
|
+
export function cleanTaskText(value, max) {
|
|
479
|
+
if (typeof value !== 'string' || !value) return '';
|
|
480
|
+
const plain = stripSgr(stripControlKeepSgr(value.slice(0, Math.max(1, max) * 4)));
|
|
481
|
+
return scrubTaskOutput(plain).trim().slice(0, max);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** A shell task's command line as the relay keeps it: cleanTaskText at 2,000 characters. */
|
|
485
|
+
export function normalizeTaskCommand(value) {
|
|
486
|
+
return cleanTaskText(value, TASK_OUTPUT_LIMITS.commandCharsMax);
|
|
487
|
+
}
|
|
488
|
+
|
|
436
489
|
// ─── Test runners ────────────────────────────────────────────────────────────
|
|
437
490
|
|
|
438
491
|
const PASS_MARK = /^\s*(?:[✓✔√]|\[PASS\])\s+/;
|
|
@@ -594,91 +647,98 @@ const RUNNER_HEADERS = [
|
|
|
594
647
|
['turbo', /^\s*•\s+Running \S+ in \d+ packages?/],
|
|
595
648
|
];
|
|
596
649
|
|
|
597
|
-
function
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
650
|
+
function newTally() {
|
|
651
|
+
return { passed: 0, failed: 0, skipped: 0, firstFailureLine: null };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// One log line of a run of `runner` into `tally`; `at` names the line for
|
|
655
|
+
// `firstFailureLine` (an index into the lines, or a byte offset).
|
|
656
|
+
function countRunnerLine(runner, text, repeat, tally, at) {
|
|
657
|
+
const markFirst = () => {
|
|
658
|
+
if (tally.firstFailureLine === null) tally.firstFailureLine = at;
|
|
605
659
|
};
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
// Progress lines: "tests/test_a.py ..F.s [ 40%]"
|
|
617
|
-
// and their wrapped continuations ("........ [ 56%]").
|
|
618
|
-
const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
|
|
619
|
-
|| /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
|
|
620
|
-
if (dots) {
|
|
621
|
-
for (const mark of dots[1]) {
|
|
622
|
-
if (mark === '.' || mark === 'X') passed += 1;
|
|
623
|
-
else if (mark === 'F' || mark === 'E') fail(index);
|
|
624
|
-
else skipped += 1;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
|
|
630
|
-
const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
|
|
631
|
-
if (!tap) return;
|
|
632
|
-
// A subtest line is indented; only top-level results count.
|
|
633
|
-
if (/^\s/.test(text)) return;
|
|
634
|
-
if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) skipped += repeat;
|
|
635
|
-
else if (tap[1]) fail(index, repeat);
|
|
636
|
-
else passed += repeat;
|
|
660
|
+
const fail = (amount = 1) => {
|
|
661
|
+
tally.failed += amount;
|
|
662
|
+
markFirst();
|
|
663
|
+
};
|
|
664
|
+
if (runner === 'pytest') {
|
|
665
|
+
const verbose = /\s(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)(?:\s+\[\s*\d+%\])?\s*$/.exec(text);
|
|
666
|
+
if (verbose) {
|
|
667
|
+
if (verbose[1] === 'PASSED' || verbose[1] === 'XPASS') tally.passed += repeat;
|
|
668
|
+
else if (verbose[1] === 'FAILED' || verbose[1] === 'ERROR') fail(repeat);
|
|
669
|
+
else tally.skipped += repeat;
|
|
637
670
|
return;
|
|
638
671
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
skipped += skippedHere;
|
|
649
|
-
if (failedHere) fail(index, failedHere);
|
|
650
|
-
return;
|
|
672
|
+
// Progress lines: "tests/test_a.py ..F.s [ 40%]"
|
|
673
|
+
// and their wrapped continuations ("........ [ 56%]").
|
|
674
|
+
const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
|
|
675
|
+
|| /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
|
|
676
|
+
if (dots) {
|
|
677
|
+
for (const mark of dots[1]) {
|
|
678
|
+
if (mark === '.' || mark === 'X') tally.passed += 1;
|
|
679
|
+
else if (mark === 'F' || mark === 'E') fail();
|
|
680
|
+
else tally.skipped += 1;
|
|
651
681
|
}
|
|
652
|
-
if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
|
|
653
|
-
return;
|
|
654
682
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
|
|
686
|
+
const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
|
|
687
|
+
if (!tap) return;
|
|
688
|
+
// A subtest line is indented; only top-level results count.
|
|
689
|
+
if (/^\s/.test(text)) return;
|
|
690
|
+
if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) tally.skipped += repeat;
|
|
691
|
+
else if (tap[1]) fail(repeat);
|
|
692
|
+
else tally.passed += repeat;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (runner === 'vitest') {
|
|
696
|
+
// File lines: " ✓ src/a.test.ts (12 tests) 34ms", " ❯ src/b.test.ts (5 tests | 2 failed) 20ms".
|
|
697
|
+
// The × lines under a failed file repeat its failures.
|
|
698
|
+
const file = /^\s*[✓❯×]\s+\S+\s+\((\d+) tests?(?: \| (\d+) failed)?(?: \| (\d+) skipped)?\)/.exec(text);
|
|
699
|
+
if (file) {
|
|
700
|
+
const total = Number(file[1]);
|
|
701
|
+
const failedHere = Number(file[2] || 0);
|
|
702
|
+
const skippedHere = Number(file[3] || 0);
|
|
703
|
+
tally.passed += Math.max(0, total - failedHere - skippedHere);
|
|
704
|
+
tally.skipped += skippedHere;
|
|
705
|
+
if (failedHere) fail(failedHere);
|
|
668
706
|
return;
|
|
669
707
|
}
|
|
670
|
-
if (
|
|
671
|
-
|
|
708
|
+
if (FAIL_MARK.test(text)) markFirst();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (runner === 'jest') {
|
|
712
|
+
// Without --verbose jest marks files only; each failed test gets a
|
|
713
|
+
// "● suite › test" block (but "● Console" is a log block).
|
|
714
|
+
if (/^\s*FAIL\s/.test(text)) {
|
|
715
|
+
markFirst();
|
|
672
716
|
return;
|
|
673
717
|
}
|
|
674
|
-
if (
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (/failing tests:\s*$/.test(text)) return;
|
|
678
|
-
fail(index, repeat);
|
|
718
|
+
if (/^\s*●\s(?!Console\b)/.test(text)) {
|
|
719
|
+
fail(repeat);
|
|
720
|
+
return;
|
|
679
721
|
}
|
|
680
|
-
|
|
681
|
-
|
|
722
|
+
if (PASS_MARK.test(text)) tally.passed += repeat;
|
|
723
|
+
else if (FAIL_MARK.test(text)) markFirst();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (runner === 'playwright' && /^\s*-\s+\d+\s/.test(text)) {
|
|
727
|
+
tally.skipped += repeat;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (PASS_MARK.test(text)) tally.passed += repeat;
|
|
731
|
+
else if (FAIL_MARK.test(text)) {
|
|
732
|
+
// node:test's "✖ failing tests:" heads the repeat of the failures.
|
|
733
|
+
if (/failing tests:\s*$/.test(text)) return;
|
|
734
|
+
fail(repeat);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function liveCounts(runner, texts) {
|
|
739
|
+
const tally = newTally();
|
|
740
|
+
texts.forEach((text, index) => countRunnerLine(runner, text, texts.counts[index], tally, index));
|
|
741
|
+
return tally;
|
|
682
742
|
}
|
|
683
743
|
|
|
684
744
|
// Where the first failure is told in detail, for a run that has its final
|
|
@@ -735,3 +795,56 @@ export function parseRunnerSummary(lines) {
|
|
|
735
795
|
if (!runner) return null;
|
|
736
796
|
return { runner, ...liveCounts(runner, texts), final: false };
|
|
737
797
|
}
|
|
798
|
+
|
|
799
|
+
// One-line closing summaries (jest, vitest, pytest, turbo, node:test's
|
|
800
|
+
// last line, Playwright's timed count).
|
|
801
|
+
const CLOSING_LINES = [
|
|
802
|
+
/^\s*Tests:\s+.*\b\d+\s+total\s*$/,
|
|
803
|
+
/^\s*Tests\s{2,}.*\(\d+\)\s*$/,
|
|
804
|
+
/^=+ .*\bin [\d.]+s(?: \([^)]*\))? =+\s*$/,
|
|
805
|
+
/^\s*Tasks:\s+\d+ successful/,
|
|
806
|
+
/^\s*(?:ℹ|#)\s+duration_ms\s+[\d.]+\s*$/,
|
|
807
|
+
/^\s{2}\d+ passed \([\dhms.\s]+\)\s*$/,
|
|
808
|
+
];
|
|
809
|
+
|
|
810
|
+
// Headers that open a new run: a second run in the same log (a watch rerun,
|
|
811
|
+
// `npm test` twice) counts from its own start. A jest or node:test file line
|
|
812
|
+
// is a header too, but every file of a run prints one.
|
|
813
|
+
function opensRun(runner, text) {
|
|
814
|
+
if (runner === 'node:test') return /^TAP version \d+/.test(text);
|
|
815
|
+
return runner === 'playwright' || runner === 'vitest' || runner === 'pytest' || runner === 'turbo';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* The live counts of a whole log fed one line at a time, for a log too long
|
|
820
|
+
* to parse at once (the health poller keeps one per task as the file grows).
|
|
821
|
+
* `push(line, at)` takes a completed line (a string or `{ text, count }`) and
|
|
822
|
+
* what names it (a byte offset); `result()` is what parseRunnerSummary says
|
|
823
|
+
* of a run still going, `{ runner, passed, failed, skipped, firstFailureLine,
|
|
824
|
+
* final: false }` with `firstFailureLine` the `at` of the first failure, or
|
|
825
|
+
* null while no runner's header has been seen. Counting starts at the
|
|
826
|
+
* header: marks before it (a build's ✓ lines) are not a test run's.
|
|
827
|
+
*/
|
|
828
|
+
export function createRunnerTally() {
|
|
829
|
+
let runner = null;
|
|
830
|
+
let tally = newTally();
|
|
831
|
+
// A closing summary was seen: the next header (a jest file line too)
|
|
832
|
+
// starts the next run.
|
|
833
|
+
let closed = false;
|
|
834
|
+
return {
|
|
835
|
+
push(line, at) {
|
|
836
|
+
const text = lineTextOf(line);
|
|
837
|
+
const header = RUNNER_HEADERS.find(([, pattern]) => pattern.test(text));
|
|
838
|
+
if (header && (!runner || closed || opensRun(header[0], text))) {
|
|
839
|
+
runner = header[0];
|
|
840
|
+
tally = newTally();
|
|
841
|
+
closed = false;
|
|
842
|
+
}
|
|
843
|
+
if (runner) countRunnerLine(runner, text, countOf(line), tally, at);
|
|
844
|
+
if (runner && CLOSING_LINES.some((pattern) => pattern.test(text))) closed = true;
|
|
845
|
+
},
|
|
846
|
+
result() {
|
|
847
|
+
return runner ? { runner, ...tally, final: false } : null;
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
@@ -69,6 +69,15 @@ export function createWorkerWebSocketLink({
|
|
|
69
69
|
minBackoffMs = 1000,
|
|
70
70
|
maxBackoffMs = 8_000,
|
|
71
71
|
jitterMs = 250,
|
|
72
|
+
// A connection counts as made only at the relay's first server.hello (the
|
|
73
|
+
// relay sends it as soon as it accepts the socket). One still connecting,
|
|
74
|
+
// or open but silent, when this runs out is given up and retried: a relay
|
|
75
|
+
// that hangs in its shutdown can hold a socket in either state without
|
|
76
|
+
// ever ending it.
|
|
77
|
+
connectTimeoutMs = 15_000,
|
|
78
|
+
// Failed attempts after the first few are logged at most once per this
|
|
79
|
+
// interval, with a count of the ones in between.
|
|
80
|
+
failureLogIntervalMs = 60_000,
|
|
72
81
|
readyRefreshMs = 10_000,
|
|
73
82
|
staleConnectionMs = 30_000,
|
|
74
83
|
WebSocketImpl = globalThis.WebSocket,
|
|
@@ -84,6 +93,10 @@ export function createWorkerWebSocketLink({
|
|
|
84
93
|
let readyRefreshTimer = null;
|
|
85
94
|
let reconnectAttempt = 0;
|
|
86
95
|
let reconnectDelayMs = 0;
|
|
96
|
+
// The current socket's connect deadline (see connectTimeoutMs).
|
|
97
|
+
let connectDeadlineTimer = null;
|
|
98
|
+
let lastFailureLoggedAt = null;
|
|
99
|
+
let failuresNotLogged = 0;
|
|
87
100
|
// Ends the current socket's connection (see connect).
|
|
88
101
|
let endCurrentSocket = null;
|
|
89
102
|
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
@@ -239,6 +252,12 @@ export function createWorkerWebSocketLink({
|
|
|
239
252
|
reconnectTimer = null;
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
function clearConnectDeadline() {
|
|
256
|
+
if (!connectDeadlineTimer) return;
|
|
257
|
+
clearTimeoutImpl(connectDeadlineTimer);
|
|
258
|
+
connectDeadlineTimer = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
function clearReadyRefreshTimer() {
|
|
243
262
|
if (!readyRefreshTimer) return;
|
|
244
263
|
clearIntervalImpl(readyRefreshTimer);
|
|
@@ -290,7 +309,23 @@ export function createWorkerWebSocketLink({
|
|
|
290
309
|
if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
|
|
291
310
|
}
|
|
292
311
|
|
|
293
|
-
|
|
312
|
+
// One line per failed attempt for the first few, then at most one per
|
|
313
|
+
// failureLogIntervalMs: a relay that stays away for an hour must not fill
|
|
314
|
+
// the worker log with a line every 8 seconds.
|
|
315
|
+
function logFailedAttempt(reason) {
|
|
316
|
+
const nowMs = getNowMs();
|
|
317
|
+
const intervalMs = Math.max(0, Number(failureLogIntervalMs) || 0);
|
|
318
|
+
if (reconnectAttempt > 3 && lastFailureLoggedAt !== null && (nowMs - lastFailureLoggedAt) < intervalMs) {
|
|
319
|
+
failuresNotLogged += 1;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const notLogged = failuresNotLogged ? [`notLogged=${failuresNotLogged}`] : [];
|
|
323
|
+
dbg("worker ws disconnected", reason, `attempt=${reconnectAttempt}`, `retryInMs=${reconnectDelayMs}`, ...notLogged);
|
|
324
|
+
lastFailureLoggedAt = nowMs;
|
|
325
|
+
failuresNotLogged = 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scheduleReconnect(reason) {
|
|
294
329
|
if (stopped) return;
|
|
295
330
|
// One timer at a time: a second end reported for the same outage must not
|
|
296
331
|
// stack another attempt or push the backoff up.
|
|
@@ -299,7 +334,7 @@ export function createWorkerWebSocketLink({
|
|
|
299
334
|
const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
|
|
300
335
|
const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
|
|
301
336
|
reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
|
|
302
|
-
|
|
337
|
+
logFailedAttempt(reason);
|
|
303
338
|
reconnectTimer = setTimeoutImpl(() => {
|
|
304
339
|
reconnectTimer = null;
|
|
305
340
|
connect();
|
|
@@ -312,42 +347,70 @@ export function createWorkerWebSocketLink({
|
|
|
312
347
|
const openState = resolveSocketStateValue(ws, "OPEN", 1);
|
|
313
348
|
const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
|
|
314
349
|
if (ws.readyState === openState || ws.readyState === connectingState) return;
|
|
350
|
+
// A socket left closing whose end was never reported: give it up
|
|
351
|
+
// quietly, this connect is its reconnect.
|
|
352
|
+
endCurrentSocket?.("replaced", { reconnect: false });
|
|
315
353
|
}
|
|
316
354
|
if (typeof WebSocketImpl !== "function") {
|
|
317
355
|
dbg("worker ws unavailable: global WebSocket is missing");
|
|
318
356
|
return;
|
|
319
357
|
}
|
|
320
358
|
clearReconnectTimer();
|
|
321
|
-
|
|
322
|
-
|
|
359
|
+
let socket = null;
|
|
360
|
+
try {
|
|
361
|
+
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
362
|
+
socket = new WebSocketImpl(url);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// A socket that fails in its constructor raises no events: nothing but
|
|
365
|
+
// this would ever try again.
|
|
366
|
+
scheduleReconnect(`connect-failed: ${error?.message || String(error)}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
323
369
|
ws = socket;
|
|
324
370
|
let serverHelloSeen = false;
|
|
325
|
-
// Set once this socket's end has been handled, by whichever of error
|
|
326
|
-
// close
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
371
|
+
// Set once this socket's end has been handled, by whichever of error,
|
|
372
|
+
// close, the connect deadline or a stale/draining close comes first;
|
|
373
|
+
// every later event from it is ignored. Node 22 fires no close for a
|
|
374
|
+
// socket closed while still connecting, and closing it from its own
|
|
375
|
+
// error handler raised the error again, so the reconnect has to be
|
|
376
|
+
// scheduled from the error as well, and only once.
|
|
330
377
|
let ended = false;
|
|
331
|
-
function endSocket(reason) {
|
|
378
|
+
function endSocket(reason, { reconnect = true } = {}) {
|
|
332
379
|
if (ended) return false;
|
|
333
380
|
ended = true;
|
|
381
|
+
if (endCurrentSocket === endSocket) {
|
|
382
|
+
endCurrentSocket = null;
|
|
383
|
+
clearConnectDeadline();
|
|
384
|
+
}
|
|
334
385
|
if (ws === socket) {
|
|
335
386
|
ws = null;
|
|
336
387
|
clearReadyRefreshTimer();
|
|
337
388
|
// The next connection may reach a different (restarted) relay.
|
|
338
389
|
serverCapabilities = new Set();
|
|
339
390
|
}
|
|
340
|
-
if (stopped) return true;
|
|
341
|
-
|
|
342
|
-
|
|
391
|
+
if (stopped || !reconnect) return true;
|
|
392
|
+
// A newer socket is already under way and owns the reconnect.
|
|
393
|
+
if (ws) return true;
|
|
394
|
+
scheduleReconnect(reason);
|
|
343
395
|
return true;
|
|
344
396
|
}
|
|
345
397
|
endCurrentSocket = endSocket;
|
|
398
|
+
clearConnectDeadline();
|
|
399
|
+
const deadlineMs = Math.max(0, Number(connectTimeoutMs) || 0);
|
|
400
|
+
if (deadlineMs > 0) {
|
|
401
|
+
connectDeadlineTimer = setTimeoutImpl(() => {
|
|
402
|
+
connectDeadlineTimer = null;
|
|
403
|
+
if (ended) return;
|
|
404
|
+
const opened = socket.readyState === resolveSocketStateValue(socket, "OPEN", 1);
|
|
405
|
+
// Ended before the close call, as in the error handler below.
|
|
406
|
+
endSocket(opened ? "no-server-hello" : "connect-timeout");
|
|
407
|
+
try { socket.close?.(); } catch {}
|
|
408
|
+
}, deadlineMs);
|
|
409
|
+
if (typeof connectDeadlineTimer?.unref === "function") connectDeadlineTimer.unref();
|
|
410
|
+
}
|
|
346
411
|
socket.addEventListener("open", () => {
|
|
347
412
|
if (ended) return;
|
|
348
413
|
const openedAt = getNowMs();
|
|
349
|
-
reconnectAttempt = 0;
|
|
350
|
-
reconnectDelayMs = 0;
|
|
351
414
|
lastOpenAt = openedAt;
|
|
352
415
|
lastMessageAt = openedAt;
|
|
353
416
|
dbg("worker ws connected");
|
|
@@ -373,6 +436,14 @@ export function createWorkerWebSocketLink({
|
|
|
373
436
|
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
374
437
|
if (!serverHelloSeen) {
|
|
375
438
|
serverHelloSeen = true;
|
|
439
|
+
// The handshake is complete: only now does the backoff start over.
|
|
440
|
+
// A relay that accepts the socket and drops it before its hello
|
|
441
|
+
// keeps backing off instead of being retried every second.
|
|
442
|
+
clearConnectDeadline();
|
|
443
|
+
reconnectAttempt = 0;
|
|
444
|
+
reconnectDelayMs = 0;
|
|
445
|
+
lastFailureLoggedAt = null;
|
|
446
|
+
failuresNotLogged = 0;
|
|
376
447
|
try {
|
|
377
448
|
onServerHello(String(payload.reason || "server-hello"));
|
|
378
449
|
} catch (error) {
|
|
@@ -388,6 +459,9 @@ export function createWorkerWebSocketLink({
|
|
|
388
459
|
}
|
|
389
460
|
if (payload?.type === "server.draining") {
|
|
390
461
|
dbg("worker ws draining", payload.reason || "relay-shutdown");
|
|
462
|
+
// A relay stuck in its shutdown may never complete the close
|
|
463
|
+
// handshake: the reconnect does not wait for the close event.
|
|
464
|
+
endSocket("draining");
|
|
391
465
|
try { socket.close?.(); } catch {}
|
|
392
466
|
return;
|
|
393
467
|
}
|
|
@@ -408,15 +482,16 @@ export function createWorkerWebSocketLink({
|
|
|
408
482
|
dbg("worker ws blocked", payload.reason || "blocked");
|
|
409
483
|
}
|
|
410
484
|
});
|
|
411
|
-
socket.addEventListener("close", () => {
|
|
412
|
-
|
|
485
|
+
socket.addEventListener("close", (event) => {
|
|
486
|
+
const code = Number(event?.code);
|
|
487
|
+
endSocket(Number.isFinite(code) && code > 0 ? `close code=${code}` : "close");
|
|
413
488
|
});
|
|
414
|
-
socket.addEventListener("error", (
|
|
489
|
+
socket.addEventListener("error", (event) => {
|
|
415
490
|
if (ended) return;
|
|
416
|
-
|
|
491
|
+
const detail = event?.message || event?.error?.message || "unknown";
|
|
417
492
|
// Ended before the close call, so an error or close it raises
|
|
418
493
|
// (synchronously on some runtimes) finds the socket already handled.
|
|
419
|
-
endSocket(
|
|
494
|
+
endSocket(`error: ${detail}`);
|
|
420
495
|
try { socket.close?.(); } catch {}
|
|
421
496
|
});
|
|
422
497
|
}
|
|
@@ -429,6 +504,7 @@ export function createWorkerWebSocketLink({
|
|
|
429
504
|
function stop() {
|
|
430
505
|
stopped = true;
|
|
431
506
|
clearReconnectTimer();
|
|
507
|
+
clearConnectDeadline();
|
|
432
508
|
clearReadyRefreshTimer();
|
|
433
509
|
if (ws) {
|
|
434
510
|
try { ws.close(); } catch {}
|