@opentermsarchive/engine 16.0.2 → 16.1.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 (51) hide show
  1. package/config/default.json +13 -0
  2. package/config/test.json +12 -0
  3. package/package.json +1 -1
  4. package/scripts/declarations/validate/index.mocha.js +7 -0
  5. package/scripts/import/index.js +1 -1
  6. package/scripts/import/loadCommits.js +1 -1
  7. package/scripts/rewrite/initializer/index.js +1 -1
  8. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  9. package/scripts/rewrite/rewrite-versions.js +1 -1
  10. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  11. package/src/archivist/fetcher/index.test.js +12 -0
  12. package/src/archivist/index.js +106 -23
  13. package/src/archivist/index.test.js +659 -9
  14. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  15. package/src/archivist/recorder/repositories/git/index.js +2 -13
  16. package/src/archivist/recorder/repositories/git/index.test.js +23 -1
  17. package/src/archivist/services/index.js +45 -1
  18. package/src/archivist/services/index.test.js +52 -1
  19. package/src/archivist/services/sourceDocument.js +8 -2
  20. package/src/archivist/services/sourceDocument.test.js +37 -0
  21. package/src/archivist/tracking-results/errors.js +5 -0
  22. package/src/archivist/tracking-results/index.js +233 -0
  23. package/src/archivist/tracking-results/index.test.js +406 -0
  24. package/src/archivist/tracking-results/recorder.js +156 -0
  25. package/src/archivist/tracking-results/recorder.test.js +559 -0
  26. package/src/archivist/tracking-results/repository.js +136 -0
  27. package/src/archivist/tracking-results/repository.test.js +763 -0
  28. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  29. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  30. package/src/archivist/tracking-results/run/index.js +115 -0
  31. package/src/archivist/tracking-results/run/index.test.js +221 -0
  32. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  33. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  34. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  35. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  36. package/src/git/errors.js +1 -0
  37. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
  38. package/src/git/index.test.js +266 -0
  39. package/src/git/pathSegment.js +18 -0
  40. package/src/git/pathSegment.test.js +33 -0
  41. package/src/index.js +5 -4
  42. package/src/reporter/factory.js +2 -2
  43. package/src/reporter/github/index.js +7 -5
  44. package/src/reporter/github/index.test.js +16 -1
  45. package/src/reporter/gitlab/index.js +6 -6
  46. package/src/reporter/gitlab/index.test.js +24 -1
  47. package/src/reporter/index.js +9 -4
  48. package/src/reporter/index.test.js +74 -9
  49. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  50. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  51. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -0,0 +1,406 @@
1
+ import { expect, use } from 'chai';
2
+ import sinon from 'sinon';
3
+ import sinonChai from 'sinon-chai';
4
+
5
+ import Git from '../../git/index.js';
6
+ import { ExtractDocumentError } from '../extract/index.js';
7
+ import { FetchDocumentError } from '../fetcher/index.js';
8
+ import { getDeclaredTerms } from '../services/index.js';
9
+ import SourceDocument from '../services/sourceDocument.js';
10
+
11
+ import { UnreadableRunError } from './errors.js';
12
+
13
+ import TrackingResults from './index.js';
14
+
15
+ use(sinonChai);
16
+
17
+ const DECLARATIONS_COMMIT = 'c0ffee1234567890c0ffee1234567890c0ffee12';
18
+
19
+ function makeRecorder(overrides = {}) {
20
+ return {
21
+ currentRun: null,
22
+ repository: { path: '/data/tracking-results' },
23
+ initialize: sinon.stub().resolves(),
24
+ finalize: sinon.stub().resolves(),
25
+ startRun: sinon.stub().resolves(),
26
+ recordTermsOutcome: sinon.stub().resolves(),
27
+ completeRun: sinon.stub().resolves(),
28
+ recoverCrashedRunIfAny: sinon.stub().resolves(null),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function makeServices() { // Minimal stand-ins for the loaded services map, exposing the getTermsTypes(filter) and getNumberOfTerms(filter) contracts used by the run start
34
+ const service = termsTypes => {
35
+ const getTermsTypes = filter => (filter?.length ? termsTypes.filter(type => filter.includes(type)) : termsTypes);
36
+
37
+ return { getTermsTypes, getNumberOfTerms: filter => getTermsTypes(filter).length };
38
+ };
39
+
40
+ return {
41
+ 'Service A': service([ 'Terms of Service', 'Privacy Policy' ]),
42
+ 'Service B': service(['Terms of Service']),
43
+ };
44
+ }
45
+
46
+ function makeTerms() {
47
+ const sourceDocument = new SourceDocument({ location: 'https://example.com/terms', contentSelectors: 'body' });
48
+
49
+ sourceDocument.mimeType = 'text/html';
50
+ sourceDocument.snapshotId = 'abc123';
51
+
52
+ return { service: { id: 'Service A', name: 'Service A' }, type: 'Terms of Service', sourceDocuments: [sourceDocument] };
53
+ }
54
+
55
+ describe('TrackingResults', () => {
56
+ let recorder;
57
+ let subject;
58
+ let warnings;
59
+
60
+ beforeEach(() => {
61
+ recorder = makeRecorder();
62
+ subject = new TrackingResults({ recorder });
63
+ warnings = [];
64
+ subject.on('warn', ({ message }) => warnings.push(message));
65
+ });
66
+
67
+ describe('.create', () => {
68
+ context('when the configured storage type is not git', () => {
69
+ it('throws an error stating the supported type', async () => {
70
+ const error = await TrackingResults.create({ storage: { type: 'mongo', mongo: {} } }).catch(error => error);
71
+
72
+ expect(error.message).to.include('only "git" is supported');
73
+ });
74
+ });
75
+ });
76
+
77
+ describe('#initialize', () => {
78
+ it('initialises the recorder and attempts crash recovery', async () => {
79
+ await subject.initialize();
80
+
81
+ expect(recorder.initialize).to.have.been.calledOnce;
82
+ expect(recorder.recoverCrashedRunIfAny).to.have.been.calledOnce;
83
+ });
84
+
85
+ context('when a crashed run has been recovered', () => {
86
+ beforeEach(() => {
87
+ recorder.recoverCrashedRunIfAny.resolves({ shortRunId: 'ota-run-f47ac10b', coverage: { processed: 2, skipped: [{ serviceId: 'Service B', termsType: 'Terms of Service', reason: 'no outcome recorded before the engine crashed' }] } });
88
+ });
89
+
90
+ it('announces it', async () => {
91
+ await subject.initialize();
92
+
93
+ expect(warnings.join('\n')).to.include('Recovered crashed run ota-run-f47ac10b: persisted 2 processed terms and 1 skipped terms');
94
+ });
95
+ });
96
+
97
+ context('when the declarations commit of the crashed run is not reachable', () => {
98
+ let declaredTerms;
99
+
100
+ beforeEach(() => {
101
+ recorder.recoverCrashedRunIfAny.callsFake(async ({ getDeclaredTermsAtCommit }) => {
102
+ declaredTerms = await getDeclaredTermsAtCommit('deadbeef'.repeat(5)); // Well-formed but unknown commit
103
+
104
+ return null;
105
+ });
106
+ });
107
+
108
+ it('falls back to the currently declared terms and reports the approximation', async () => {
109
+ await subject.initialize();
110
+
111
+ expect(declaredTerms).to.have.deep.members(await getDeclaredTerms());
112
+ expect(warnings.join('\n')).to.include('is not reachable');
113
+ });
114
+ });
115
+
116
+ context('when the repository cannot be initialized', () => {
117
+ beforeEach(() => {
118
+ recorder.initialize.onFirstCall().rejects(new Error('Unable to create index.lock'));
119
+ });
120
+
121
+ it('degrades with a warning instead of failing the engine initialization', async () => {
122
+ await subject.initialize();
123
+
124
+ expect(recorder.recoverCrashedRunIfAny).to.not.have.been.called;
125
+ expect(warnings.join('\n')).to.include('Unable to create index.lock');
126
+ });
127
+
128
+ it('points operators to the repository to inspect if the warning persists', async () => {
129
+ await subject.initialize();
130
+
131
+ expect(warnings.join('\n')).to.include('inspect or delete the repository at "/data/tracking-results"');
132
+ });
133
+
134
+ it('retries the initialization at the next run', async () => {
135
+ await subject.initialize();
136
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
137
+
138
+ expect(recorder.initialize).to.have.been.calledTwice;
139
+ expect(recorder.startRun).to.have.been.calledOnce;
140
+ });
141
+ });
142
+ });
143
+
144
+ describe('#getDeclarationsCommit', () => {
145
+ afterEach(() => Git.getHeadSha.restore());
146
+
147
+ it('resolves to the commit of the declarations repository', async () => {
148
+ sinon.stub(Git, 'getHeadSha').resolves(DECLARATIONS_COMMIT);
149
+
150
+ expect(await subject.getDeclarationsCommit()).to.equal(DECLARATIONS_COMMIT);
151
+ expect(warnings).to.be.empty;
152
+ });
153
+
154
+ context('when the declarations are not versioned', () => {
155
+ it('resolves to null and reports it', async () => {
156
+ sinon.stub(Git, 'getHeadSha').resolves(null);
157
+
158
+ expect(await subject.getDeclarationsCommit()).to.be.null;
159
+ expect(warnings.join('\n')).to.include('is not a Git repository');
160
+ });
161
+ });
162
+
163
+ context('when the commit cannot be read', () => {
164
+ it('resolves to null and reports it', async () => {
165
+ sinon.stub(Git, 'getHeadSha').rejects(new Error('git failure'));
166
+
167
+ expect(await subject.getDeclarationsCommit()).to.be.null;
168
+ expect(warnings.join('\n')).to.include('Could not read the declarations commit: git failure');
169
+ });
170
+ });
171
+ });
172
+
173
+ describe('#startRun', () => {
174
+ it('starts a recorder run with the full declared counts and the unselected terms as skips', async () => {
175
+ const services = makeServices();
176
+
177
+ await subject.startRun({ services, declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
178
+
179
+ expect(recorder.startRun).to.have.been.calledOnce;
180
+
181
+ const { declarationsCommit, servicesCount, termsCount, skippedTerms } = recorder.startRun.firstCall.args[0];
182
+
183
+ expect(declarationsCommit).to.equal(DECLARATIONS_COMMIT);
184
+ expect(servicesCount).to.equal(2);
185
+ expect(termsCount).to.equal(3);
186
+ expect(skippedTerms).to.deep.equal([{ serviceId: 'Service B', termsType: 'Terms of Service', reason: 'not selected for this run' }]);
187
+ });
188
+
189
+ it('narrows the skips to the selected terms types', async () => {
190
+ const services = makeServices();
191
+
192
+ await subject.startRun({ services, declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: [ 'Service A', 'Service B' ], selectedTermsTypes: ['Privacy Policy'] });
193
+
194
+ const { skippedTerms } = recorder.startRun.firstCall.args[0];
195
+
196
+ expect(skippedTerms).to.deep.equal([
197
+ { serviceId: 'Service A', termsType: 'Terms of Service', reason: 'not selected for this run' },
198
+ { serviceId: 'Service B', termsType: 'Terms of Service', reason: 'not selected for this run' },
199
+ ]);
200
+ });
201
+
202
+ context('when the declarations commit is unknown', () => {
203
+ it('does not start a recorder run', async () => {
204
+ await subject.startRun({ services: makeServices(), declarationsCommit: null, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
205
+
206
+ expect(recorder.startRun).to.not.have.been.called;
207
+ });
208
+ });
209
+
210
+ context('when the recorder cannot start the run', () => {
211
+ beforeEach(() => {
212
+ recorder.startRun.rejects(new Error('Run start commit failed'));
213
+ });
214
+
215
+ it('degrades with a warning instead of failing the tracking run', async () => {
216
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
217
+
218
+ expect(subject.hasRunInProgress).to.be.false;
219
+ expect(warnings.join('\n')).to.include('tracking-results is disabled for this run');
220
+ });
221
+ });
222
+
223
+ context('when crash recovery keeps failing', () => {
224
+ beforeEach(() => {
225
+ recorder.recoverCrashedRunIfAny.rejects(new Error('transient git failure'));
226
+ });
227
+
228
+ it('does not start a recorder run', async () => { // A new Start run commit would hide the crashed run's reference SHA and make its recovery impossible forever
229
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
230
+
231
+ expect(recorder.startRun).to.not.have.been.called;
232
+ expect(warnings.join('\n')).to.include('will be retried at the next one');
233
+ });
234
+
235
+ it('retries the recovery at the next run', async () => {
236
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
237
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
238
+
239
+ expect(recorder.recoverCrashedRunIfAny).to.have.been.calledTwice;
240
+ });
241
+ });
242
+
243
+ context('when the previous run.json is unreadable', () => {
244
+ beforeEach(() => {
245
+ recorder.recoverCrashedRunIfAny.rejects(new UnreadableRunError('Could not parse JSON'));
246
+ });
247
+
248
+ it('proceeds and announces the file will be overwritten', async () => {
249
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
250
+
251
+ expect(recorder.startRun).to.have.been.calledOnce;
252
+ expect(warnings.join('\n')).to.include('it will be overwritten by the next run');
253
+ });
254
+ });
255
+
256
+ context('when recovery already succeeded', () => {
257
+ it('does not attempt it again', async () => {
258
+ await subject.initialize();
259
+ await subject.startRun({ services: makeServices(), declarationsCommit: DECLARATIONS_COMMIT, selectedServicesIds: ['Service A'], selectedTermsTypes: [] });
260
+
261
+ expect(recorder.recoverCrashedRunIfAny).to.have.been.calledOnce;
262
+ });
263
+ });
264
+ });
265
+
266
+ describe('#recordSuccess', () => {
267
+ context('when a run is in progress', () => {
268
+ beforeEach(() => {
269
+ recorder.currentRun = {};
270
+ });
271
+
272
+ it('maps the terms to the recorder payload', async () => {
273
+ await subject.recordSuccess(makeTerms());
274
+
275
+ expect(recorder.recordTermsOutcome).to.have.been.calledOnce;
276
+ expect(recorder.recordTermsOutcome.firstCall.args[0]).to.deep.equal({
277
+ serviceId: 'Service A',
278
+ termsType: 'Terms of Service',
279
+ serviceName: 'Service A',
280
+ sourceDocuments: [{
281
+ id: 'terms',
282
+ fetch: 'https://example.com/terms',
283
+ select: 'body',
284
+ remove: undefined,
285
+ filter: undefined,
286
+ executeClientScripts: undefined,
287
+ mimeType: 'text/html',
288
+ snapshotId: 'abc123',
289
+ }],
290
+ status: 'ok',
291
+ reasons: undefined,
292
+ transientErrorReasons: undefined,
293
+ });
294
+ });
295
+
296
+ it('normalises the MIME type reported by the fetcher', async () => {
297
+ const terms = makeTerms();
298
+
299
+ terms.sourceDocuments[0].mimeType = 'text/html; charset=utf-8';
300
+ await subject.recordSuccess(terms);
301
+
302
+ expect(recorder.recordTermsOutcome.firstCall.args[0].sourceDocuments[0].mimeType).to.equal('text/html');
303
+ });
304
+
305
+ it('records a missing MIME type as null', async () => {
306
+ const terms = makeTerms();
307
+
308
+ terms.sourceDocuments[0].mimeType = undefined;
309
+ await subject.recordSuccess(terms);
310
+
311
+ expect(recorder.recordTermsOutcome.firstCall.args[0].sourceDocuments[0].mimeType).to.be.null;
312
+ });
313
+
314
+ it('categorises the transient errors', async () => {
315
+ await subject.recordSuccess(makeTerms(), { transientErrors: [new FetchDocumentError('HTTP code 503')] });
316
+
317
+ expect(recorder.recordTermsOutcome.firstCall.args[0].transientErrorReasons).to.deep.equal(['[fetch] Fetch failed: HTTP code 503']);
318
+ });
319
+ });
320
+
321
+ context('when no run is in progress', () => {
322
+ it('records nothing', async () => {
323
+ await subject.recordSuccess(makeTerms());
324
+
325
+ expect(recorder.recordTermsOutcome).to.not.have.been.called;
326
+ });
327
+ });
328
+ });
329
+
330
+ describe('#recordFailure', () => {
331
+ beforeEach(() => {
332
+ recorder.currentRun = {};
333
+ });
334
+
335
+ it('categorises each error by origin', async () => {
336
+ await subject.recordFailure(makeTerms(), [
337
+ new FetchDocumentError('HTTP code 500'),
338
+ new ExtractDocumentError('CSS selector has no match'),
339
+ new Error('Could not commit /home/ota/data/snapshots/Service/Terms.html'),
340
+ ]);
341
+
342
+ expect(recorder.recordTermsOutcome.firstCall.args[0].status).to.equal('failed');
343
+ expect(recorder.recordTermsOutcome.firstCall.args[0].reasons).to.deep.equal([
344
+ '[fetch] Fetch failed: HTTP code 500',
345
+ '[extraction] Extract failed: CSS selector has no match',
346
+ '[internal] Unexpected engine error',
347
+ ]);
348
+ });
349
+ });
350
+
351
+ describe('#completeRun and #finalize', () => {
352
+ it('delegate to the recorder', async () => {
353
+ await subject.initialize();
354
+ await subject.completeRun();
355
+ await subject.finalize();
356
+
357
+ expect(recorder.completeRun).to.have.been.calledOnce;
358
+ expect(recorder.finalize).to.have.been.calledOnce;
359
+ });
360
+
361
+ context('when the repository could not be initialized', () => {
362
+ beforeEach(() => {
363
+ recorder.initialize.rejects(new Error('Unable to create index.lock'));
364
+ });
365
+
366
+ it('does not finalize it', async () => {
367
+ await subject.initialize();
368
+ await subject.finalize();
369
+
370
+ expect(recorder.finalize).to.not.have.been.called;
371
+ });
372
+ });
373
+
374
+ context('when the push fails at finalize', () => {
375
+ beforeEach(() => {
376
+ recorder.finalize.rejects(new Error('remote rejected'));
377
+ });
378
+
379
+ it('degrades with a warning, as the commits remain local', async () => {
380
+ await subject.initialize();
381
+ await subject.finalize();
382
+
383
+ expect(warnings.join('\n')).to.include('recorded commits are kept locally');
384
+ });
385
+ });
386
+ });
387
+
388
+ describe('#hasRunInProgress', () => {
389
+ it('reflects the recorder state', () => {
390
+ expect(subject.hasRunInProgress).to.be.false;
391
+ recorder.currentRun = {};
392
+ expect(subject.hasRunInProgress).to.be.true;
393
+ });
394
+ });
395
+
396
+ describe('#currentRunId', () => {
397
+ it('returns null when no run is in progress', () => {
398
+ expect(subject.currentRunId).to.be.null;
399
+ });
400
+
401
+ it('returns the id of the run in progress', () => {
402
+ recorder.currentRun = { runId: 'ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479' };
403
+ expect(subject.currentRunId).to.equal('ota-run-f47ac10b-58cc-4372-a567-0e02b2c3d479');
404
+ });
405
+ });
406
+ });
@@ -0,0 +1,156 @@
1
+ import { UnreadableRunError } from './errors.js';
2
+ import Run, { RUN_STATUSES } from './run/index.js';
3
+ import { TRANSITIONS_BY_EVENT_TYPE, termsKey } from './terms-result/dataMapper.js';
4
+ import TermsResult, { STATUSES } from './terms-result/index.js';
5
+
6
+ export const RUN_ID_TRAILER_KEY = 'x-run-id'; // Git trailer tying every commit of a run to its runId, so run membership stays greppable without relying on commit ranges between two run.json commits; parseTrailers lowercases keys, hence the casing
7
+
8
+ export default class TrackingResultsRecorder {
9
+ constructor({ repository, collectionId, schedule, engineVersion }) {
10
+ this.repository = repository;
11
+ this.collectionId = collectionId;
12
+ this.schedule = schedule;
13
+ this.engineVersion = engineVersion;
14
+ this.currentRun = null;
15
+ }
16
+
17
+ async initialize() {
18
+ await this.repository.initialize();
19
+ }
20
+
21
+ finalize() {
22
+ return this.repository.finalize();
23
+ }
24
+
25
+ async startRun({ declarationsCommit, servicesCount, termsCount, skippedTerms = [] }) {
26
+ const run = new Run({
27
+ runId: Run.generateId(),
28
+ collectionId: this.collectionId,
29
+ schedule: this.schedule,
30
+ startDate: new Date().toISOString(),
31
+ engineVersion: this.engineVersion,
32
+ declarationsCommit,
33
+ servicesCount,
34
+ termsCount,
35
+ });
36
+
37
+ skippedTerms.forEach(skipped => run.addSkipped(skipped)); // Skips known at run start (e.g. terms not selected by a partial run) are persisted in the run-start commit: a crash during the run must not let recovery attribute them to the crash
38
+
39
+ await this.repository.saveRun(run, { trailers: runTrailers(run) });
40
+
41
+ this.currentRun = run; // Assigned only once the run-start commit landed: a failed start must not leave a half-open run that later recordings would attach to
42
+
43
+ return run;
44
+ }
45
+
46
+ async recordTermsOutcome({ serviceId, termsType, serviceName, sourceDocuments, status, reasons, transientErrorReasons }) {
47
+ const run = this.activeRun;
48
+
49
+ const event = {
50
+ date: new Date().toISOString(),
51
+ serviceName,
52
+ sourceDocuments,
53
+ };
54
+
55
+ if (status === STATUSES.failed) {
56
+ event.reasons = reasons;
57
+ }
58
+
59
+ if (transientErrorReasons?.length) {
60
+ event.transientError = { reasons: transientErrorReasons };
61
+ }
62
+
63
+ const newResult = new TermsResult({ serviceId, termsType, status, event });
64
+ const { eventType } = await this.repository.saveTermsResult(newResult, { trailers: runTrailers(run) });
65
+
66
+ run.coverage.processed++; // Counted per call: every terms of a run is either recorded once or persisted as skipped at run start; the Archivist is responsible for that invariant
67
+ run.tracked[status]++;
68
+
69
+ if (event.transientError) {
70
+ run.transientErrors++;
71
+ }
72
+
73
+ const transitionType = TRANSITIONS_BY_EVENT_TYPE[eventType];
74
+
75
+ if (transitionType) {
76
+ run.recordTransition(transitionType, { serviceId, termsType });
77
+ }
78
+ }
79
+
80
+ async completeRun() {
81
+ const run = this.activeRun;
82
+
83
+ run.markCompleted(new Date().toISOString());
84
+
85
+ await this.repository.saveRun(run, { trailers: runTrailers(run) });
86
+
87
+ this.currentRun = null;
88
+ }
89
+
90
+ async recoverCrashedRunIfAny({ getDeclaredTermsAtCommit }) { // Must be invoked before startRun: a new run-start commit would hide the crashed run's reference SHA from findLatestRunCommitSha. The callback returns the terms declared at a given declarations commit, since the recorder has no knowledge of the declarations module
91
+ let run;
92
+
93
+ try {
94
+ run = await this.repository.findLatestRun();
95
+ } catch (error) {
96
+ throw error.code ? error : new UnreadableRunError(error.message, { cause: error }); // System errors carry a code and are worth a retry, unlike a content that cannot be parsed or validated
97
+ }
98
+
99
+ if (!run || run.lastRun.status !== RUN_STATUSES.inProgress) {
100
+ return null;
101
+ }
102
+
103
+ const runStartSha = await this.repository.findLatestRunCommitSha();
104
+ const committedTerms = await this.repository.findCommittedTermsResultsSince(runStartSha);
105
+ const persistedSkipped = run.coverage.skipped; // Skips persisted by the run-start commit (e.g. terms not selected by a partial run) are preserved with their original reason, not re-attributed to the crash
106
+ const accountedKeys = new Set([ ...committedTerms, ...persistedSkipped ].map(({ serviceId, termsType }) => termsKey(serviceId, termsType)));
107
+ const declaredTerms = await getDeclaredTermsAtCommit(run.declarations.commit);
108
+ const crashSkipped = declaredTerms
109
+ .filter(({ serviceId, termsType }) => !accountedKeys.has(termsKey(serviceId, termsType)))
110
+ .map(({ serviceId, termsType }) => ({ serviceId, termsType, reason: 'no outcome recorded before the engine crashed' })); // Terms processed without a substantive change leave no commit, so they cannot be told apart from unprocessed ones and end up here, understating `processed`: the reason states the absence of a record rather than claiming the terms was not processed
111
+
112
+ run.coverage = { processed: committedTerms.length, skipped: [ ...persistedSkipped, ...crashSkipped ] };
113
+ ({ tracked: run.tracked, transientErrors: run.transientErrors } = await this.deriveCounts(committedTerms));
114
+ run.markCrashed(new Date().toISOString()); // run.transitions is left as persisted by the run-start commit (empty): deriving it back would mean parsing commit subjects, and the transitions of a crashed run remain derivable by consumers from its per-terms commits
115
+ await this.repository.saveRun(run, { trailers: runTrailers(run) }); // Carries the crashed run's id, so the finalization commit is greppable alongside the run it closes
116
+
117
+ return run;
118
+ }
119
+
120
+ async deriveCounts(committedTerms) { // The committed terms files still hold the crashed run's last written state, since recovery runs before any new startRun
121
+ const tracked = { ok: 0, failed: 0 };
122
+ let transientErrors = 0;
123
+
124
+ for (const { serviceId, termsType } of committedTerms) {
125
+ const result = await this.repository.findLatestTermsResult(serviceId, termsType);
126
+
127
+ if (!result) { // Defensive: a file removed by hand since the crash leaves the counts unchanged rather than failing the recovery
128
+ continue;
129
+ }
130
+
131
+ tracked[result.status]++;
132
+
133
+ if (result.event.transientError) {
134
+ transientErrors++;
135
+ }
136
+ }
137
+
138
+ return { tracked, transientErrors };
139
+ }
140
+
141
+ discardCurrentRun() { // Test entry point to drop the run in progress without persisting anything
142
+ this.currentRun = null;
143
+ }
144
+
145
+ get activeRun() { // Single guarded access path to the run in progress: dereferencing it before startRun throws, so no mutating method needs a separate assertion. External readers keep using the nullable currentRun property
146
+ if (!this.currentRun) {
147
+ throw new Error(`${this.constructor.name}: no tracking run in progress; call startRun first`);
148
+ }
149
+
150
+ return this.currentRun;
151
+ }
152
+ }
153
+
154
+ function runTrailers(run) {
155
+ return { [RUN_ID_TRAILER_KEY]: run.runId };
156
+ }