@jrpool/kilotest 59.1.4 → 60.0.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 (62) hide show
  1. package/README.md +2 -5
  2. package/api/awaitTest.test.ts +9 -9
  3. package/api/awaitTest.ts +12 -12
  4. package/api/getReport.test.ts +4 -4
  5. package/api/getReport.ts +9 -9
  6. package/api/listDiagnoses.test.ts +14 -14
  7. package/api/listDiagnoses.ts +8 -8
  8. package/api/listIssues.test.ts +15 -15
  9. package/api/listIssues.ts +18 -20
  10. package/api/listPages.test.ts +2 -2
  11. package/api/listPages.ts +4 -4
  12. package/api/listViolators.test.ts +10 -10
  13. package/api/listViolators.ts +10 -10
  14. package/api/orderNewTest.test.ts +1 -2
  15. package/api/orderNewTest.ts +3 -4
  16. package/api/orderRetest.test.ts +10 -10
  17. package/api/orderRetest.ts +13 -14
  18. package/api/requestRetest.test.ts +11 -11
  19. package/api/requestRetest.ts +11 -11
  20. package/api/routes.ts +9 -11
  21. package/api/schemas.ts +18 -28
  22. package/api/util.test.ts +2 -2
  23. package/api/util.ts +7 -7
  24. package/capability.md +4 -4
  25. package/docs/AI-TOOLS.md +41 -1
  26. package/docs/decisions/009-report-id.md +16 -0
  27. package/index.test.ts +91 -41
  28. package/index.ts +66 -15
  29. package/lhm.plugin.json +33 -119
  30. package/llms-full.txt +18 -24
  31. package/llms.txt +12 -12
  32. package/mcp.test.ts +11 -20
  33. package/mcp.ts +18 -18
  34. package/openapi-3.0.yaml +1847 -0
  35. package/openapi.yaml +38 -97
  36. package/package.json +1 -1
  37. package/routing.test.ts +1 -1
  38. package/scripts/generate-openapi.ts +83 -1
  39. package/server.json +1 -1
  40. package/util.test.ts +32 -33
  41. package/util.ts +47 -43
  42. package/web/hideReportForm/index.ts +5 -6
  43. package/web/listDiagnoses/index.ts +4 -5
  44. package/web/listDiagnoses/listDiagnoses.test.ts +13 -13
  45. package/web/listIssues/index.ts +11 -12
  46. package/web/listIssues/listIssues.test.ts +24 -24
  47. package/web/listPages/index.ts +2 -2
  48. package/web/listPages/listPages.test.ts +2 -2
  49. package/web/listTopIssues/index.ts +2 -2
  50. package/web/listViolators/index.ts +5 -6
  51. package/web/listViolators/listViolators.test.ts +12 -12
  52. package/web/reannotate/index.ts +3 -3
  53. package/web/reannotateForm/index.ts +1 -2
  54. package/web/reportDeletion.ts +9 -12
  55. package/web/requestRetest/index.ts +2 -2
  56. package/web/requestRetest/requestRetest.test.ts +7 -7
  57. package/web/requestRetestForm/index.html +2 -2
  58. package/web/requestRetestForm/index.ts +3 -4
  59. package/web/requestRetestForm/requestRetestForm.test.ts +7 -8
  60. package/web/unhideReportForm/index.ts +4 -6
  61. package/web/util.test.ts +29 -29
  62. package/web/util.ts +1 -1
package/index.test.ts CHANGED
@@ -341,29 +341,29 @@ test('GET /api/listPages returns JSON with report data and records an API-operat
341
341
  assert.equal(await getMetricCount('apiOperations', 'listPages'), countBefore + 1);
342
342
  });
343
343
 
344
- test('GET /api/listIssues/260101T0000/mix returns JSON with issue data', async () => {
345
- const res = await request('GET', '/api/listIssues/260101T0000/mix');
344
+ test('GET /api/listIssues/260101T0000-mix returns JSON with issue data', async () => {
345
+ const res = await request('GET', '/api/listIssues/260101T0000-mix');
346
346
  assert.equal(res.statusCode, 200);
347
347
  const body = jsonBody(res);
348
348
  assert.equal(body['tool name'], 'listIssues');
349
349
  });
350
350
 
351
- test('GET /api/listViolators/linkNoText/260101T0000/mix returns JSON', async () => {
352
- const res = await request('GET', '/api/listViolators/linkNoText/260101T0000/mix');
351
+ test('GET /api/listViolators/linkNoText/260101T0000-mix returns JSON', async () => {
352
+ const res = await request('GET', '/api/listViolators/linkNoText/260101T0000-mix');
353
353
  assert.equal(res.statusCode, 200);
354
354
  const body = jsonBody(res);
355
355
  assert.equal(body['tool name'], 'listViolators');
356
356
  });
357
357
 
358
- test('GET /api/listDiagnoses/0/linkNoText/260101T0000/mix returns JSON', {timeout: 500}, async () => {
359
- const res = await request('GET', '/api/listDiagnoses/0/linkNoText/260101T0000/mix');
358
+ test('GET /api/listDiagnoses/0/linkNoText/260101T0000-mix returns JSON', {timeout: 500}, async () => {
359
+ const res = await request('GET', '/api/listDiagnoses/0/linkNoText/260101T0000-mix');
360
360
  assert.equal(res.statusCode, 200);
361
361
  const body = jsonBody(res);
362
362
  assert.equal(body['tool name'], 'listDiagnoses');
363
363
  });
364
364
 
365
- test('GET /api/getReport/260101T0000/mix returns JSON with the report', async () => {
366
- const res = await request('GET', '/api/getReport/260101T0000/mix');
365
+ test('GET /api/getReport/260101T0000-mix returns JSON with the report', async () => {
366
+ const res = await request('GET', '/api/getReport/260101T0000-mix');
367
367
  assert.equal(res.statusCode, 200);
368
368
  const body = jsonBody(res);
369
369
  assert.equal(body['tool name'], 'getReport');
@@ -376,8 +376,8 @@ test('GET /api/invalidService returns an error', {timeout: 500}, async () => {
376
376
  assert.ok(body.error);
377
377
  });
378
378
 
379
- test('GET /fullReport.json/260101T0000/mix downloads the report as JSON', async () => {
380
- const res = await request('GET', '/fullReport.json/260101T0000/mix');
379
+ test('GET /fullReport.json/260101T0000-mix downloads the report as JSON', async () => {
380
+ const res = await request('GET', '/fullReport.json/260101T0000-mix');
381
381
  assert.equal(res.statusCode, 200);
382
382
  assert.ok(res.headers['content-type'].includes('application/json'));
383
383
  assert.ok(res.headers['content-disposition'].includes('attachment'));
@@ -392,8 +392,8 @@ test('GET /fullReport.json/invalid/invalid returns an error page', {timeout: 500
392
392
  assert.ok(res.body.includes('Invalid report request'));
393
393
  });
394
394
 
395
- test('GET /fullReport.json/990101T0000/xxx returns an abuse error for a nonexistent report', async () => {
396
- const res = await request('GET', '/fullReport.json/990101T0000/xxx');
395
+ test('GET /fullReport.json/990101T0000-xxx returns an abuse error for a nonexistent report', async () => {
396
+ const res = await request('GET', '/fullReport.json/990101T0000-xxx');
397
397
  assert.equal(res.statusCode, 400);
398
398
  assert.ok(res.body.includes('Invalid request'));
399
399
  });
@@ -495,10 +495,10 @@ test('POST /requestNewTest.html with an unreadable body returns an error', async
495
495
  assert.ok(res.body.includes('Unreadable request body'));
496
496
  });
497
497
 
498
- test('POST /requestRetest.html/260202T0000/new with valid data returns HTML', async () => {
498
+ test('POST /requestRetest.html/260202T0000-new with valid data returns HTML', async () => {
499
499
  // Reset testRequests.json to avoid duplicate-request errors from prior tests.
500
500
  await fs.writeFile(testRequestsPath, '{}\n');
501
- const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
501
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000-new', {
502
502
  why: 'Because the report is obsolete and needs refreshing'
503
503
  });
504
504
  assert.equal(res.statusCode, 200);
@@ -524,8 +524,8 @@ test('POST /api/requestNewTest with valid JSON returns a JSON response', {timeou
524
524
  assert.ok(body['tool name'] || body['response content']);
525
525
  });
526
526
 
527
- test('POST /api/requestRetest/260202T0000/new with valid JSON returns a JSON response', async () => {
528
- const res = await request('POST', '/api/requestRetest/260202T0000/new', {
527
+ test('POST /api/requestRetest/260202T0000-new with valid JSON returns a JSON response', async () => {
528
+ const res = await request('POST', '/api/requestRetest/260202T0000-new', {
529
529
  reason: 'Because the report is obsolete and needs refreshing'
530
530
  });
531
531
  assert.equal(res.statusCode, 200);
@@ -557,12 +557,12 @@ test('POST /api/orderNewTest with valid JSON enqueues a job directly and records
557
557
  assert.equal(await getMetricCount('apiOperations', 'orderNewTest'), countBefore + 1);
558
558
  const reportIdentifier = body['response content']['disposition of your order']['report identifier'];
559
559
  assert.ok(reportIdentifier);
560
- await fs.unlink(path.join(fixtureDBDir, 'jobs', 'queue', `${reportIdentifier.timeStamp}-${reportIdentifier.jobID}.json`));
560
+ await fs.unlink(path.join(fixtureDBDir, 'jobs', 'queue', `${reportIdentifier}.json`));
561
561
  });
562
562
 
563
- test('POST /api/orderRetest/260202T0000/new with valid JSON enqueues a job directly and records an API-operation metric', async () => {
563
+ test('POST /api/orderRetest/260202T0000-new with valid JSON enqueues a job directly and records an API-operation metric', async () => {
564
564
  const countBefore = await getMetricCount('apiOperations', 'orderRetest');
565
- const res = await request('POST', '/api/orderRetest/260202T0000/new', {
565
+ const res = await request('POST', '/api/orderRetest/260202T0000-new', {
566
566
  reason: 'Because the report is obsolete and needs refreshing'
567
567
  });
568
568
  assert.equal(res.statusCode, 200);
@@ -571,12 +571,12 @@ test('POST /api/orderRetest/260202T0000/new with valid JSON enqueues a job direc
571
571
  assert.equal(await getMetricCount('apiOperations', 'orderRetest'), countBefore + 1);
572
572
  const reportIdentifier = body['response content']['disposition of your order']['report identifier'];
573
573
  assert.ok(reportIdentifier);
574
- await fs.unlink(path.join(fixtureDBDir, 'jobs', 'queue', `${reportIdentifier.timeStamp}-${reportIdentifier.jobID}.json`));
574
+ await fs.unlink(path.join(fixtureDBDir, 'jobs', 'queue', `${reportIdentifier}.json`));
575
575
  });
576
576
 
577
- test('POST /api/awaitTest/260101T0000/mix with an already-completed report records an API-operation metric', async () => {
577
+ test('POST /api/awaitTest/260101T0000-mix with an already-completed report records an API-operation metric', async () => {
578
578
  const countBefore = await getMetricCount('apiOperations', 'awaitTest');
579
- const res = await request('POST', '/api/awaitTest/260101T0000/mix', {});
579
+ const res = await request('POST', '/api/awaitTest/260101T0000-mix', {});
580
580
  assert.equal(res.statusCode, 200);
581
581
  const body = jsonBody(res);
582
582
  assert.equal(body['response content']['disposition of your wait'].outcome, 'completed');
@@ -771,8 +771,8 @@ test('POST /tutorialWebComment.html with content returns JSON', async () => {
771
771
 
772
772
  // TESTS: additional branch coverage
773
773
 
774
- test('GET /fullReport.json/260101T0007/hid returns an error for an unavailable report', async () => {
775
- const res = await request('GET', '/fullReport.json/260101T0007/hid');
774
+ test('GET /fullReport.json/260101T0007-hid returns an error for an unavailable report', async () => {
775
+ const res = await request('GET', '/fullReport.json/260101T0007-hid');
776
776
  assert.equal(res.statusCode, 400);
777
777
  assert.ok(res.body.includes('Invalid request'));
778
778
  });
@@ -906,24 +906,74 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
906
906
 
907
907
  test('GET /diagnoses.html/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
908
908
  const res = await request(
909
- 'GET', '/diagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body', {}, {followRedirects: false}
909
+ 'GET', '/diagnoses.html/duplicateID/260426T1741-77w/718?pathID=/html/body', {}, {followRedirects: false}
910
910
  );
911
911
  assert.equal(res.statusCode, 301);
912
- assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body');
912
+ assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741-77w/718?pathID=/html/body');
913
913
  });
914
914
 
915
- test('GET /reportIssues.html/<timeStamp>/<jobID> redirects to the equivalent /listIssues.html/<timeStamp>/<jobID>', async () => {
916
- const res = await request('GET', '/reportIssues.html/260101T0000/mix', {}, {followRedirects: false});
915
+ test('GET /reportIssues.html/<reportID> redirects to the equivalent /listIssues.html/<reportID>', async () => {
916
+ const res = await request('GET', '/reportIssues.html/260101T0000-mix', {}, {followRedirects: false});
917
917
  assert.equal(res.statusCode, 301);
918
- assert.equal(res.headers['location'], '/listIssues.html/260101T0000/mix');
918
+ assert.equal(res.headers['location'], '/listIssues.html/260101T0000-mix');
919
+ });
920
+
921
+ // TESTS: legacy two-segment report-identifier redirects
922
+ // Before the reportID consolidation, report-identifying URL tails used a
923
+ // timeStamp segment and a jobID segment. These verify that each legacy form
924
+ // permanently redirects to the single-reportID form, and that a POST redirect
925
+ // uses 308 to preserve the method and body.
926
+
927
+ test('GET /listIssues.html/<timeStamp>/<jobID> redirects to the single-reportID URL', async () => {
928
+ const res = await request('GET', '/listIssues.html/260101T0000/mix', {}, {followRedirects: false});
929
+ assert.equal(res.statusCode, 301);
930
+ assert.equal(res.headers['location'], '/listIssues.html/260101T0000-mix');
931
+ });
932
+
933
+ test('GET /listViolators.html/<issueID>/<timeStamp>/<jobID> redirects to the single-reportID URL', async () => {
934
+ const res = await request(
935
+ 'GET', '/listViolators.html/linkNoText/260101T0000/mix', {}, {followRedirects: false}
936
+ );
937
+ assert.equal(res.statusCode, 301);
938
+ assert.equal(res.headers['location'], '/listViolators.html/linkNoText/260101T0000-mix');
939
+ });
940
+
941
+ test('GET /listDiagnoses.html/<issueID>/<timeStamp>/<jobID>/<catalogIndex> redirects to the single-reportID URL, preserving the query string', async () => {
942
+ const res = await request(
943
+ 'GET', '/listDiagnoses.html/linkNoText/260101T0000/mix/0?pathID=/html/body', {}, {followRedirects: false}
944
+ );
945
+ assert.equal(res.statusCode, 301);
946
+ assert.equal(res.headers['location'], '/listDiagnoses.html/linkNoText/260101T0000-mix/0?pathID=/html/body');
947
+ });
948
+
949
+ test('GET /requestRetestForm.html/<timeStamp>/<jobID> redirects to the single-reportID URL', async () => {
950
+ const res = await request(
951
+ 'GET', '/requestRetestForm.html/260202T0000/new', {}, {followRedirects: false}
952
+ );
953
+ assert.equal(res.statusCode, 301);
954
+ assert.equal(res.headers['location'], '/requestRetestForm.html/260202T0000-new');
955
+ });
956
+
957
+ test('GET /fullReport.json/<timeStamp>/<jobID> redirects to the single-reportID URL', async () => {
958
+ const res = await request('GET', '/fullReport.json/260101T0000/mix', {}, {followRedirects: false});
959
+ assert.equal(res.statusCode, 301);
960
+ assert.equal(res.headers['location'], '/fullReport.json/260101T0000-mix');
961
+ });
962
+
963
+ test('POST /requestRetest.html/<timeStamp>/<jobID> redirects with 308 to the single-reportID URL', async () => {
964
+ const res = await formRequest(
965
+ 'POST', '/requestRetest.html/260202T0000/new', {why: 'Because the report is obsolete and needs refreshing'}
966
+ );
967
+ assert.equal(res.statusCode, 308);
968
+ assert.equal(res.headers['location'], '/requestRetest.html/260202T0000-new');
919
969
  });
920
970
 
921
971
  test('GET /reportIssue.html/<segments> redirects to the equivalent /listViolators.html/<segments>', async () => {
922
972
  const res = await request(
923
- 'GET', '/reportIssue.html/duplicateID/260101T0000/mix', {}, {followRedirects: false}
973
+ 'GET', '/reportIssue.html/duplicateID/260101T0000-mix', {}, {followRedirects: false}
924
974
  );
925
975
  assert.equal(res.statusCode, 301);
926
- assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000/mix');
976
+ assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000-mix');
927
977
  });
928
978
 
929
979
  test('GET /rules.html/<segment> redirects to the equivalent /listRules.html/<segment>', async () => {
@@ -1007,8 +1057,8 @@ test('POST /requestNewTest.html with a private-address URL returns an error unle
1007
1057
  }
1008
1058
  });
1009
1059
 
1010
- test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
1011
- const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
1060
+ test('POST /requestRetest.html/260202T0000-new with missing why returns an error', async () => {
1061
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000-new', {
1012
1062
  why: ''
1013
1063
  });
1014
1064
  assert.equal(res.statusCode, 400);
@@ -1464,8 +1514,8 @@ test('GET /requestNewTest.html without arguments is rejected as an invalid GET r
1464
1514
  assert.equal(res.statusCode, 400);
1465
1515
  });
1466
1516
 
1467
- test('GET /requestRetest.html/260202T0000/new is rejected as an invalid GET request', async () => {
1468
- const res = await request('GET', '/requestRetest.html/260202T0000/new');
1517
+ test('GET /requestRetest.html/260202T0000-new is rejected as an invalid GET request', async () => {
1518
+ const res = await request('GET', '/requestRetest.html/260202T0000-new');
1469
1519
  assert.equal(res.statusCode, 400);
1470
1520
  });
1471
1521
 
@@ -1476,8 +1526,8 @@ test('GET /requestRetest.html/260202T0000/new is rejected as an invalid GET requ
1476
1526
 
1477
1527
  const htmlPagePaths = [
1478
1528
  '/listPages.html',
1479
- '/listViolators.html/linkNoText/260101T0000/mix',
1480
- '/listDiagnoses.html/linkNoText/260101T0000/mix/0',
1529
+ '/listViolators.html/linkNoText/260101T0000-mix',
1530
+ '/listDiagnoses.html/linkNoText/260101T0000-mix/0',
1481
1531
  '/enqueueForm.html',
1482
1532
  '/manage.html',
1483
1533
  '/tutorialWeb.html',
@@ -1839,11 +1889,11 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
1839
1889
  // First retest to create the recommendation. As above, the reason must match
1840
1890
  // exactly between the two requests for processTestRequest to treat the second as
1841
1891
  // a duplicate rather than a second, independent (and thus also 'ok') request.
1842
- await formRequest('POST', '/requestRetest.html/260202T0000/new', {
1892
+ await formRequest('POST', '/requestRetest.html/260202T0000-new', {
1843
1893
  why: 'Because the report is obsolete and needs refreshing'
1844
1894
  });
1845
1895
  // Second, identical retest of the same report should get a duplicate error.
1846
- const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
1896
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000-new', {
1847
1897
  why: 'Because the report is obsolete and needs refreshing'
1848
1898
  });
1849
1899
  assert.equal(res.statusCode, 400);
@@ -1985,7 +2035,7 @@ test('a request logs its path without its query string', async () => {
1985
2035
  const originalLog = console.log;
1986
2036
  console.log = (line: string) => logged.push(line);
1987
2037
  try {
1988
- await request('GET', '/listIssues.html/260101T0000/mix?authCode=secret');
2038
+ await request('GET', '/listIssues.html/260101T0000-mix?authCode=secret');
1989
2039
  }
1990
2040
  finally {
1991
2041
  console.log = originalLog;
@@ -1994,7 +2044,7 @@ test('a request logs its path without its query string', async () => {
1994
2044
  .map(line => { try {return JSON.parse(line);} catch {return null;} })
1995
2045
  .find(parsed => parsed?.type === 'request');
1996
2046
  assert.ok(record);
1997
- assert.equal(record.path, '/listIssues.html/260101T0000/mix');
2047
+ assert.equal(record.path, '/listIssues.html/260101T0000-mix');
1998
2048
  assert.ok(!JSON.stringify(record).includes('secret'));
1999
2049
  });
2000
2050
 
package/index.ts CHANGED
@@ -23,8 +23,7 @@ import {
23
23
  isReportError,
24
24
  getReportPath,
25
25
  hiddenReportsPath,
26
- isTimeStamp,
27
- isJobID,
26
+ isReportID,
28
27
  isURL,
29
28
  isUsableReport,
30
29
  isValidAuthCode,
@@ -306,6 +305,17 @@ const renamedPagePrefixes: Record<string, string> = {
306
305
  requestTest: 'requestNewTest',
307
306
  requestTestForm: 'requestNewTestForm'
308
307
  };
308
+ // Pages whose report-identifying path tails once used two segments (a timeStamp
309
+ // segment and a jobID segment) where they now use one reportID segment. Each value
310
+ // gives the index of the first legacy segment within the path tail and the total
311
+ // segment count of the legacy tail.
312
+ // TEMPORARY: retire these redirects after 2027-04-17 once bookmarked links have aged out.
313
+ const legacyReportTails: Record<string, {idIndex: number; segmentCount: number}> = {
314
+ listDiagnoses: {idIndex: 1, segmentCount: 4},
315
+ listIssues: {idIndex: 0, segmentCount: 2},
316
+ listViolators: {idIndex: 1, segmentCount: 3},
317
+ requestRetestForm: {idIndex: 0, segmentCount: 2}
318
+ };
309
319
  const jobLock = createLock();
310
320
 
311
321
  // FUNCTIONS
@@ -325,6 +335,24 @@ export const isPathAllowed = (method: string, pathname: string): boolean => {
325
335
  const patterns = (routes as Record<string, string[]>)[method] || [];
326
336
  return patterns.some(pattern => matchPath(pattern, pathname));
327
337
  };
338
+ // Returns the single-reportID URL that a legacy two-segment report URL (one whose
339
+ // path tail identifies a report with separate timeStamp and jobID segments)
340
+ // redirects to, or null if the page has no legacy form or the tail lacks its shape.
341
+ const getLegacyReportURL = (topic: string, pathTail: string): string | null => {
342
+ const legacy = legacyReportTails[topic];
343
+ if (!legacy) {
344
+ return null;
345
+ }
346
+ const segments = pathTail.split('/');
347
+ const reportID = `${segments[legacy.idIndex]}-${segments[legacy.idIndex + 1]}`;
348
+ if (segments.length !== legacy.segmentCount || !isReportID(reportID)) {
349
+ return null;
350
+ }
351
+ const newTail = [
352
+ ...segments.slice(0, legacy.idIndex), reportID, ...segments.slice(legacy.idIndex + 2)
353
+ ].join('/');
354
+ return `/${topic}.html/${newTail}`;
355
+ };
328
356
  // Serves or sends an error message.
329
357
  export const serveError = async (error: Record<string, unknown>, response: ServerResponse, isHumanUser = true, statusCode = 400): Promise<void> => {
330
358
  const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
@@ -711,11 +739,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
711
739
  }
712
740
  // Otherwise, if it is for a full report download:
713
741
  else if (pageName === 'fullReport.json') {
714
- const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
715
- // If the request is syntactically valid:
716
- if (isTimeStamp(timeStamp) && isJobID(jobID)) {
742
+ const segments = pathTail.split('/');
743
+ const legacyID = `${segments[0]}-${segments[1]}`;
744
+ // If the request identifies the report with the legacy two-segment form (a
745
+ // timeStamp segment and a jobID segment), redirect the request permanently to
746
+ // the single-reportID URL, preserving any query string.
747
+ if (segments.length === 2 && isReportID(legacyID)) {
748
+ response.writeHead(301, {Location: `/fullReport.json/${legacyID}${search}`});
749
+ response.end();
750
+ }
751
+ // Otherwise, if the request is syntactically valid:
752
+ else if (isReportID(pathTail)) {
717
753
  // Get it.
718
- const report = await getReport(timeStamp, jobID);
754
+ const report = await getReport(pathTail);
719
755
  // If this failed:
720
756
  if (isReportError(report)) {
721
757
  // This is not necessarily abuse: the link may have been generated (e.g. by
@@ -725,11 +761,11 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
725
761
  // but alert a manager with the real reason, since a syntactically valid report ID
726
762
  // should otherwise always be usable.
727
763
  console.error(
728
- `Full report ${timeStamp}-${jobID} requested but unavailable (${report.error})`
764
+ `Full report ${pathTail} requested but unavailable (${report.error})`
729
765
  );
730
766
  await sendAlert(
731
767
  'Kilotest: requested full report unavailable',
732
- `Full report ${timeStamp}-${jobID} was requested but could not be retrieved: ${report.error}`
768
+ `Full report ${pathTail} was requested but could not be retrieved: ${report.error}`
733
769
  );
734
770
  await serveError({message: 'ERROR: Invalid request'}, response, true);
735
771
  }
@@ -738,7 +774,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
738
774
  // Serve response headers for a JSON download.
739
775
  setHeaders('application/json', null, 'low');
740
776
  response.setHeader(
741
- 'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
777
+ 'content-disposition', `attachment; filename="${pathTail}.json"`,
742
778
  );
743
779
  // Download the report.
744
780
  response.end(getJSON(report));
@@ -753,6 +789,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
753
789
  // Otherwise, if it is for an HTML page other than the home page:
754
790
  else if (pageName.endsWith('.html')) {
755
791
  const topic = pageName.slice(0, -5);
792
+ const legacyReportURL = getLegacyReportURL(topic, pathTail);
756
793
  // If it is for a page's old, pre-rename name:
757
794
  if (renamedPagePrefixes[topic]) {
758
795
  // Redirect the client permanently to the current page name, preserving any
@@ -761,6 +798,13 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
761
798
  response.writeHead(301, {Location: newLocation});
762
799
  response.end();
763
800
  }
801
+ // Otherwise, if the URL identifies a report with the legacy two-segment form
802
+ // (a timeStamp segment and a jobID segment), redirect the request permanently
803
+ // to the URL with the single reportID segment, preserving the query string.
804
+ else if (legacyReportURL !== null) {
805
+ response.writeHead(301, {Location: `${legacyReportURL}${search}`});
806
+ response.end();
807
+ }
764
808
  // Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
765
809
  // the '*.html*' GET pattern, but its handler expects POST's argument list and
766
810
  // performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
@@ -985,9 +1029,17 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
985
1029
  // Otherwise, if it is a retest request:
986
1030
  else if (pageName === 'requestRetest.html') {
987
1031
  const {why} = postData as {why?: string};
988
- const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
989
- // If the request is valid:
990
- if (isTimeStamp(timeStamp) && isJobID(jobID) && why) {
1032
+ const segments = pathTail.split('/');
1033
+ const legacyID = `${segments[0]}-${segments[1]}`;
1034
+ // If the request identifies the report with the legacy two-segment form,
1035
+ // redirect it to the single-reportID URL. A 308 (not 301) keeps the POST
1036
+ // method and body, which a 301 could rewrite to a GET with no body.
1037
+ if (segments.length === 2 && isReportID(legacyID)) {
1038
+ response.writeHead(308, {Location: `/requestRetest.html/${legacyID}`});
1039
+ response.end();
1040
+ }
1041
+ // Otherwise, if the request is valid:
1042
+ else if (isReportID(pathTail) && why) {
991
1043
  // Serve response headers.
992
1044
  setHeaders('text/html', pathname, 'ultra');
993
1045
  // Get the answer data.
@@ -1138,9 +1190,8 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1138
1190
  const reportObj: Partial<Report> = report ?? {};
1139
1191
  const {id, target} = reportObj;
1140
1192
  const {what: description, url} = (target ?? {}) as Partial<NonNullable<Report['target']>>;
1141
- const [timeStamp, jobID] = (id?.split('-') ?? ['', '']) as [string, string];
1142
1193
  // If the request is syntactically valid:
1143
- if (id && isTimeStamp(timeStamp) && isJobID(jobID) && description && url) {
1194
+ if (id && isReportID(id) && description && url) {
1144
1195
  // Get the job the report is from. A missing claimed-job file (the job was
1145
1196
  // never claimed, or was already completed) is a normal outcome, handled below
1146
1197
  // as if the job were not assigned to this worker; any other failure to read it
@@ -1186,7 +1237,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1186
1237
  // could fail even though the just-written report is fine.
1187
1238
  await annotateReportObject(reportObj);
1188
1239
  // Save the annotated report.
1189
- await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
1240
+ await fs.writeFile(getReportPath(id), getJSON(report));
1190
1241
  console.log(`Testaro report ${id} was annotated and saved`);
1191
1242
  // Check the monetary balances and send alerts if nearing exhaustion.
1192
1243
  await checkBalancesForAlerts(report!);