start-command 0.32.1 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -28,11 +28,15 @@
28
28
  * --use-command-stream Use command-stream library for command execution (experimental)
29
29
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
30
30
  * --status <uuid> Show status of a previous command execution by UUID
31
- * --list List all tracked command executions
31
+ * --list [--running] List all (or only running) tracked command executions
32
32
  * --upload-log <uuid-or-session> Upload the stored log for a tracked execution
33
33
  * --output-format <format> Output format for status/list (links-notation, json, text)
34
34
  * --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
35
35
  * --terminate <uuid-or-session-name> Terminate a detached execution immediately
36
+ * --attach <uuid-or-session-name> Attach to a running detached session terminal
37
+ * --read-only Follow --attach output without forwarding stdin
38
+ * --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
39
+ * --resume-all Re-attach or reconcile every execution still marked running
36
40
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
37
41
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
38
42
  */
@@ -40,6 +44,13 @@
40
44
  const { getDefaultDockerImage } = require('./docker-utils');
41
45
  const dockerNetworkOptions = require('./docker-network-options');
42
46
  const { parseSequence, isSequence } = require('./sequence-parser');
47
+ const { buildCommandString } = require('./shell-utils');
48
+ const {
49
+ VALID_OUTPUT_FORMATS,
50
+ createQueryOptionDefaults,
51
+ parseQueryOption,
52
+ validateQueryOptions,
53
+ } = require('./args-parser-queries');
43
54
 
44
55
  // Debug mode from environment
45
56
  const DEBUG =
@@ -60,15 +71,6 @@ const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
60
71
  */
61
72
  const MAX_ISOLATION_DEPTH = 7;
62
73
 
63
- /**
64
- * Valid output formats for --status
65
- */
66
- const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
67
-
68
- function hasValue(value) {
69
- return value !== null && value !== undefined;
70
- }
71
-
72
74
  /**
73
75
  * UUID v4 regex pattern for validation
74
76
  */
@@ -89,18 +91,25 @@ function isValidUUID(str) {
89
91
  * @returns {string} A new UUID v4 string
90
92
  */
91
93
  function generateUUID() {
92
- // Try to use Node.js/Bun crypto module
93
- try {
94
- const crypto = require('crypto');
94
+ const crypto = require('crypto');
95
+ if (typeof crypto.randomUUID === 'function') {
95
96
  return crypto.randomUUID();
96
- } catch {
97
- // Fallback for environments without crypto.randomUUID
98
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
99
- const r = (Math.random() * 16) | 0;
100
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
101
- return v.toString(16);
102
- });
103
97
  }
98
+
99
+ // Fallback for runtimes without crypto.randomUUID (Node < 14.17). Still a
100
+ // CSPRNG: session ids name isolation sessions and users, so a predictable
101
+ // generator is a security bug, not a cosmetic one (CodeQL js/insecure-randomness).
102
+ const bytes = crypto.randomBytes(16);
103
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
104
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
105
+ const hex = bytes.toString('hex');
106
+ return [
107
+ hex.slice(0, 8),
108
+ hex.slice(8, 12),
109
+ hex.slice(12, 16),
110
+ hex.slice(16, 20),
111
+ hex.slice(20),
112
+ ].join('-');
104
113
  }
105
114
 
106
115
  /**
@@ -164,6 +173,8 @@ function parseEndpointValue(value, options) {
164
173
  * Parse command line arguments into wrapper options and command
165
174
  * @param {string[]} args - Array of command line arguments
166
175
  * @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
176
+ * `command` re-quotes each argv element so the inner shell sees the argument
177
+ * boundaries the user typed (issue #164); `rawCommand` keeps the raw argv.
167
178
  */
168
179
  function parseArgs(args) {
169
180
  const wrapperOptions = {
@@ -195,14 +206,7 @@ function parseArgs(args) {
195
206
  keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
196
207
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
197
208
  useCommandStream: false, // Use command-stream library for command execution
198
- status: null, // UUID to show status for
199
- list: false, // List all tracked execution records
200
- uploadLog: null, // UUID/session name whose stored log should be uploaded
201
- outputFormat: null, // Output format for status/list (links-notation, json, text)
202
- stop: null, // UUID/session name to stop gracefully
203
- terminate: null, // UUID/session name to terminate immediately
204
- cleanup: false, // Clean up stale "executing" records
205
- cleanupDryRun: false, // Show what would be cleaned without actually cleaning
209
+ ...createQueryOptionDefaults(),
206
210
  };
207
211
 
208
212
  let commandArgs = [];
@@ -241,7 +245,7 @@ function parseArgs(args) {
241
245
 
242
246
  return {
243
247
  wrapperOptions,
244
- command: commandArgs.join(' '),
248
+ command: buildCommandString(commandArgs),
245
249
  rawCommand: commandArgs,
246
250
  };
247
251
  }
@@ -529,125 +533,10 @@ function parseOption(args, index, options) {
529
533
  return 1;
530
534
  }
531
535
 
532
- // --status <uuid-or-session-name>
533
- if (arg === '--status') {
534
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
535
- options.status = args[index + 1];
536
- return 2;
537
- } else {
538
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
539
- }
540
- }
541
-
542
- // --status=<value>
543
- if (arg.startsWith('--status=')) {
544
- const value = arg.slice('--status='.length);
545
- if (!value) {
546
- throw new Error(
547
- `Option --status requires a UUID or session name argument`
548
- );
549
- }
550
- options.status = value;
551
- return 1;
552
- }
553
-
554
- // --upload-log <uuid-or-session-name>
555
- if (arg === '--upload-log') {
556
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
557
- options.uploadLog = args[index + 1];
558
- return 2;
559
- } else {
560
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
561
- }
562
- }
563
-
564
- // --upload-log=<value>
565
- if (arg.startsWith('--upload-log=')) {
566
- const value = arg.slice('--upload-log='.length);
567
- if (!value) {
568
- throw new Error(
569
- `Option --upload-log requires a UUID or session name argument`
570
- );
571
- }
572
- options.uploadLog = value;
573
- return 1;
574
- }
575
-
576
- // --stop <uuid-or-session-name>
577
- if (arg === '--stop') {
578
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
579
- options.stop = args[index + 1];
580
- return 2;
581
- } else {
582
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
583
- }
584
- }
585
-
586
- // --stop=<value>
587
- if (arg.startsWith('--stop=')) {
588
- const value = arg.slice('--stop='.length);
589
- if (!value) {
590
- throw new Error(`Option --stop requires a UUID or session name argument`);
591
- }
592
- options.stop = value;
593
- return 1;
594
- }
595
-
596
- // --terminate <uuid-or-session-name>
597
- if (arg === '--terminate') {
598
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
599
- options.terminate = args[index + 1];
600
- return 2;
601
- } else {
602
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
603
- }
604
- }
605
-
606
- // --terminate=<value>
607
- if (arg.startsWith('--terminate=')) {
608
- const value = arg.slice('--terminate='.length);
609
- if (!value) {
610
- throw new Error(
611
- `Option --terminate requires a UUID or session name argument`
612
- );
613
- }
614
- options.terminate = value;
615
- return 1;
616
- }
617
-
618
- // --list
619
- if (arg === '--list') {
620
- options.list = true;
621
- return 1;
622
- }
623
-
624
- // --output-format <format>
625
- if (arg === '--output-format') {
626
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
627
- options.outputFormat = args[index + 1].toLowerCase();
628
- return 2;
629
- } else {
630
- throw new Error(`Option ${arg} requires a format argument`);
631
- }
632
- }
633
-
634
- // --output-format=<value>
635
- if (arg.startsWith('--output-format=')) {
636
- options.outputFormat = arg.split('=')[1].toLowerCase();
637
- return 1;
638
- }
639
-
640
- // --cleanup
641
- if (arg === '--cleanup') {
642
- options.cleanup = true;
643
- return 1;
644
- }
645
-
646
- // --cleanup-dry-run
647
- if (arg === '--cleanup-dry-run') {
648
- options.cleanup = true;
649
- options.cleanupDryRun = true;
650
- return 1;
536
+ // Query/control options (--status, --list, --attach, --resume, ...)
537
+ const queryConsumed = parseQueryOption(args, index, options);
538
+ if (queryConsumed > 0) {
539
+ return queryConsumed;
651
540
  }
652
541
 
653
542
  // Not a recognized wrapper option
@@ -889,37 +778,7 @@ function validateOptions(options) {
889
778
  throw new Error('--keep-user option is only valid with --isolated-user');
890
779
  }
891
780
 
892
- // Validate output format
893
- if (hasValue(options.outputFormat)) {
894
- if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
895
- throw new Error(
896
- `Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
897
- );
898
- }
899
- }
900
-
901
- // Query/control modes are mutually exclusive
902
- const queryModes = [
903
- hasValue(options.status),
904
- options.list,
905
- hasValue(options.uploadLog),
906
- hasValue(options.stop),
907
- hasValue(options.terminate),
908
- options.cleanup,
909
- ].filter(Boolean).length;
910
-
911
- if (queryModes > 1) {
912
- throw new Error(
913
- 'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
914
- );
915
- }
916
-
917
- // Output format is only valid with read-only query modes
918
- if (options.outputFormat && !options.status && !options.list) {
919
- throw new Error(
920
- '--output-format option is only valid with --status or --list'
921
- );
922
- }
781
+ validateQueryOptions(options);
923
782
 
924
783
  // Validate shell option
925
784
  if (options.shell !== null && options.shell !== undefined) {
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Detached completion finalizer (issue #170)
3
+ *
4
+ * A detached Docker session outlives the CLI process that started it: the
5
+ * foreground `start` invocation returns as soon as the container is up, so
6
+ * nobody is left to write the terminal state back into the execution store.
7
+ * The record therefore stayed `executing` forever, with `exitCode: null` and
8
+ * `endTime: null`, and every `--status` query had to re-derive the outcome from
9
+ * scratch — fabricating `endTime` with `new Date()` in the process.
10
+ *
11
+ * The detached completion watcher (see `docker-cleanup.js`) now re-invokes this
12
+ * module once the container is gone, handing over the facts it already read out
13
+ * of a single `docker inspect`. This module is deliberately tiny and
14
+ * dependency-light: it is executed as a short-lived child process on a host
15
+ * whose CLI invocation is long gone, so it must never throw and never block.
16
+ */
17
+
18
+ const path = require('path');
19
+
20
+ const {
21
+ SHELL_VARS,
22
+ describeExitCode,
23
+ normalizeDockerTimestamp,
24
+ } = require('./docker-post-mortem');
25
+ const { shellQuote } = require('./isolation-log-utils');
26
+
27
+ /** Provenance markers for `endTime` (issue #170.2). */
28
+ const END_TIME_SOURCE = {
29
+ /** `docker inspect .State.FinishedAt` — the container's own clock. */
30
+ DOCKER_FINISHED_AT: 'docker-finished-at',
31
+ /** The anchored `Finished:` line `start` itself wrote into the log. */
32
+ LOG_FOOTER: 'log-footer',
33
+ /** No real finish time exists: this is when the end was *observed*. */
34
+ OBSERVED_AT: 'observed-at',
35
+ };
36
+
37
+ /**
38
+ * Persist the terminal state of a detached execution.
39
+ *
40
+ * Purely additive with respect to the observation-vs-verdict discipline of
41
+ * issues #148/#151/#162: `oomKilled` and `exitReason` are recorded as facts
42
+ * next to the exit code, never as a substitute for it.
43
+ *
44
+ * @param {Object} options - Finalization input
45
+ * @param {Object} options.store - An `ExecutionStore`
46
+ * @param {string} options.executionId - UUID (or session name) of the record
47
+ * @param {number|string|null} [options.exitCode] - `.State.ExitCode`
48
+ * @param {boolean|string|null} [options.oomKilled] - `.State.OOMKilled`
49
+ * @param {string|null} [options.startedAt] - `.State.StartedAt`
50
+ * @param {string|null} [options.finishedAt] - `.State.FinishedAt`
51
+ * @param {string|null} [options.containerError] - `.State.Error`
52
+ * @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
53
+ */
54
+ function finalizeDetachedExecution(options = {}) {
55
+ const { store, executionId } = options;
56
+ if (!store || !executionId) {
57
+ return { updated: false, reason: 'missing-arguments' };
58
+ }
59
+
60
+ let record;
61
+ try {
62
+ record = store.get(executionId);
63
+ } catch {
64
+ return { updated: false, reason: 'store-unreadable' };
65
+ }
66
+ if (!record) {
67
+ return { updated: false, reason: 'record-not-found' };
68
+ }
69
+ if (record.status === 'executed' && record.endTime) {
70
+ // Already finalized (e.g. a `--status` query got there first, or the
71
+ // watcher ran twice after a resume). Nothing to correct.
72
+ return { updated: false, reason: 'already-final', record };
73
+ }
74
+
75
+ const described = describeExitCode(options.exitCode);
76
+ record.status = 'executed';
77
+ if (described.code !== null) {
78
+ record.exitCode = described.code;
79
+ } else if (record.exitCode === null || record.exitCode === undefined) {
80
+ record.exitCode = -1;
81
+ }
82
+
83
+ const finishedAt = normalizeDockerTimestamp(options.finishedAt);
84
+ if (finishedAt) {
85
+ record.endTime = new Date(finishedAt).toISOString();
86
+ record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
87
+ } else {
88
+ // Docker has no finish time for this container (it never started, or it
89
+ // was removed before we could look). Record *when we noticed*, and say so,
90
+ // rather than passing observation time off as a finish time (issue #170.2).
91
+ record.endTime = new Date().toISOString();
92
+ record.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
93
+ record.observedAt = record.endTime;
94
+ }
95
+
96
+ const startedAt = normalizeDockerTimestamp(options.startedAt);
97
+ if (startedAt) {
98
+ record.containerStartedAt = startedAt;
99
+ }
100
+
101
+ const oomKilled = normalizeBoolean(options.oomKilled);
102
+ if (oomKilled !== null) {
103
+ record.oomKilled = oomKilled;
104
+ }
105
+
106
+ const exitReason = resolveReason(record);
107
+ if (exitReason) {
108
+ record.exitReason = exitReason;
109
+ }
110
+
111
+ const containerError = normalizeContainerError(options.containerError);
112
+ if (containerError) {
113
+ record.options = { ...(record.options || {}), containerError };
114
+ }
115
+
116
+ try {
117
+ store.save(record);
118
+ } catch (err) {
119
+ return { updated: false, reason: `save-failed: ${err.message}`, record };
120
+ }
121
+ return { updated: true, record };
122
+ }
123
+
124
+ /**
125
+ * Derive the `exitReason` hint from the same evidence `--status` would use.
126
+ * Kept in a helper so a failure to read the log never aborts finalization.
127
+ * @param {Object} record - Execution record being finalized
128
+ * @returns {string|null} Reason hint, or null
129
+ */
130
+ function resolveReason(record) {
131
+ try {
132
+ const { resolveExitReason } = require('./exit-reason');
133
+ const {
134
+ FATAL_MARKER_TAIL_BYTES,
135
+ readLogTail,
136
+ } = require('./isolation-log-utils');
137
+ const logTail = record.logPath
138
+ ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
139
+ : null;
140
+ return resolveExitReason({
141
+ exitCode: record.exitCode,
142
+ logTail,
143
+ oomKilled: record.oomKilled,
144
+ });
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @param {boolean|string|null|undefined} value - Raw shell-provided flag
152
+ * @returns {boolean|null} Parsed flag, or null when it carries no information
153
+ */
154
+ function normalizeBoolean(value) {
155
+ if (typeof value === 'boolean') {
156
+ return value;
157
+ }
158
+ if (value === 'true') {
159
+ return true;
160
+ }
161
+ if (value === 'false') {
162
+ return false;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * @param {string|null|undefined} value - Raw `.State.Error`
169
+ * @returns {string|null} Error text, or null when docker reported none
170
+ */
171
+ function normalizeContainerError(value) {
172
+ if (typeof value !== 'string') {
173
+ return null;
174
+ }
175
+ const trimmed = value.trim();
176
+ if (trimmed === '' || trimmed === '<no value>' || trimmed === '(none)') {
177
+ return null;
178
+ }
179
+ return trimmed;
180
+ }
181
+
182
+ /**
183
+ * Reconcile an in-memory record with one the detached watcher already
184
+ * finalized.
185
+ *
186
+ * A container that exits almost immediately can be finalized by the watcher
187
+ * before the foreground `start` invocation writes its own last update. Saving
188
+ * the stale in-memory copy would resurrect `status: executing` and throw the
189
+ * terminal facts away, so the stored record wins and only the fields the
190
+ * foreground process learned (its `options`, e.g. `containerId`) are merged in.
191
+ *
192
+ * @param {Object} store - An `ExecutionStore`
193
+ * @param {Object} record - The in-memory record about to be saved
194
+ * @returns {Object} The record to save
195
+ */
196
+ function reconcileFinalizedRecord(store, record) {
197
+ try {
198
+ const stored = store.get(record.uuid);
199
+ if (!stored || stored.status !== 'executed' || !stored.endTime) {
200
+ return record;
201
+ }
202
+ stored.options = { ...(stored.options || {}), ...(record.options || {}) };
203
+ return stored;
204
+ } catch {
205
+ return record;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Shell fragment that hands the inspected docker facts to this module.
211
+ *
212
+ * Runs with the same runtime that started the session, inherits
213
+ * `START_APP_FOLDER`/`START_DISABLE_TRACKING` from the detached watcher, and is
214
+ * suffixed with `|| true` so a finalization failure can never abort the
215
+ * watcher's remaining cleanup work.
216
+ *
217
+ * @param {string} executionId - UUID of the record to finalize
218
+ * @returns {string} POSIX shell fragment
219
+ */
220
+ function buildDetachedFinalizeSnippet(executionId) {
221
+ const v = SHELL_VARS;
222
+ return (
223
+ `${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
224
+ `${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
225
+ `"$${v.started}" "$${v.finished}" "$${v.error}" ` +
226
+ `>/dev/null 2>&1 || true`
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Entry point used by the detached watcher:
232
+ * <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error>
233
+ * Always exits 0 — a bookkeeping failure must never turn into a visible error
234
+ * in a log the user is reading for the command's own output.
235
+ * @param {string[]} argv - Positional arguments
236
+ * @returns {void}
237
+ */
238
+ function main(argv) {
239
+ const [
240
+ executionId,
241
+ exitCode,
242
+ oomKilled,
243
+ startedAt,
244
+ finishedAt,
245
+ containerError,
246
+ ] = argv;
247
+ if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
248
+ return;
249
+ }
250
+ try {
251
+ const { ExecutionStore } = require('./execution-store');
252
+ const store = new ExecutionStore(
253
+ process.env.START_APP_FOLDER
254
+ ? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
255
+ : {}
256
+ );
257
+ finalizeDetachedExecution({
258
+ store,
259
+ executionId,
260
+ exitCode,
261
+ oomKilled,
262
+ startedAt,
263
+ finishedAt,
264
+ containerError,
265
+ });
266
+ } catch {
267
+ // Deliberately silent: see the exit-0 contract above.
268
+ }
269
+ }
270
+
271
+ if (require.main === module) {
272
+ main(process.argv.slice(2));
273
+ }
274
+
275
+ module.exports = {
276
+ END_TIME_SOURCE,
277
+ buildDetachedFinalizeSnippet,
278
+ finalizeDetachedExecution,
279
+ main,
280
+ reconcileFinalizedRecord,
281
+ };