start-command 0.37.9 → 0.39.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 (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/bin/cli.js +62 -26
  7. package/src/lib/args-parser-queries.js +45 -1
  8. package/src/lib/args-parser.js +19 -0
  9. package/src/lib/cgroup-memory.js +95 -23
  10. package/src/lib/command-builder.js +4 -0
  11. package/src/lib/container-removal.js +4 -5
  12. package/src/lib/detached-finalize.js +1 -0
  13. package/src/lib/docker-command-handoff.js +34 -6
  14. package/src/lib/docker-init-option.js +65 -0
  15. package/src/lib/docker-inspect-batch.js +176 -0
  16. package/src/lib/docker-resource-options.js +21 -2
  17. package/src/lib/docker-runtime-args.js +19 -3
  18. package/src/lib/execution-attempt.js +19 -9
  19. package/src/lib/execution-chain.js +182 -0
  20. package/src/lib/execution-control.js +8 -6
  21. package/src/lib/execution-env.js +120 -0
  22. package/src/lib/execution-identity.js +180 -0
  23. package/src/lib/execution-recovery.js +21 -1
  24. package/src/lib/execution-resume.js +21 -1
  25. package/src/lib/execution-store.js +30 -23
  26. package/src/lib/exit-evidence.js +139 -7
  27. package/src/lib/isolation.js +21 -3
  28. package/src/lib/launch-identity-options.js +81 -0
  29. package/src/lib/launch-identity.js +411 -0
  30. package/src/lib/log-uploader.js +15 -1
  31. package/src/lib/output-blocks.js +11 -0
  32. package/src/lib/query-commands.js +18 -3
  33. package/src/lib/resume-resources.js +2 -2
  34. package/src/lib/screen-isolation.js +11 -2
  35. package/src/lib/spawn-helpers.js +33 -16
  36. package/src/lib/status-formatter.js +39 -131
  37. package/src/lib/status-probe.js +149 -0
  38. package/src/lib/store-decode-cache.js +141 -0
  39. package/src/lib/usage.js +11 -1
  40. package/test/args-parser-attach-resume.js +1 -1
  41. package/test/args-parser-control.js +2 -2
  42. package/test/cargo-lock-version.mjs +121 -0
  43. package/test/check-rust-coverage.mjs +71 -0
  44. package/test/check-web-archive.mjs +154 -0
  45. package/test/ci-workflow-invariants.js +204 -0
  46. package/test/command-builder-docker-cleanup.js +24 -0
  47. package/test/command-exit-codes.mjs +91 -0
  48. package/test/docker-runtime-options.js +2 -0
  49. package/test/docker-snapshot-safety.js +39 -0
  50. package/test/execution-resume.js +2 -0
  51. package/test/fixtures/fake-npm.sh +64 -0
  52. package/test/fixtures/lychee-report.md +39 -0
  53. package/test/javascript-first-ci.js +69 -0
  54. package/test/links-workflow.mjs +127 -0
  55. package/test/lint-gate.mjs +56 -0
  56. package/test/npm-publish.mjs +181 -0
  57. package/test/recheck-broken-links.mjs +452 -0
  58. package/test/regression-176.js +2 -0
  59. package/test/regression-187.js +2 -0
  60. package/test/regression-191.js +4 -0
  61. package/test/regression-193.js +2 -0
  62. package/test/regression-198.js +2 -0
  63. package/test/regression-207.js +8 -2
  64. package/test/regression-224.js +2 -0
  65. package/test/regression-229.js +267 -0
  66. package/test/regression-230.js +234 -0
  67. package/test/regression-231.js +298 -0
  68. package/test/regression-236.js +250 -0
  69. package/test/regression-238.js +129 -0
  70. package/test/regression-241.js +254 -0
  71. package/test/regression-242.js +241 -0
  72. package/test/regression-243.js +493 -0
  73. package/test/regression-244.js +471 -0
  74. package/test/release-push-retry.mjs +172 -0
  75. package/test/run-js-tests.mjs +15 -3
  76. package/test/setup/private-snapshot-lock.js +17 -0
  77. package/test/shared-golden.js +44 -2
  78. package/test/transient-links.mjs +139 -0
@@ -60,6 +60,7 @@ function buildNextLevelCommand(options, command) {
60
60
  for (const [key, flag] of [
61
61
  ['memory', '--memory'],
62
62
  ['memorySwap', '--memory-swap'],
63
+ ['memoryReservation', '--memory-reservation'],
63
64
  ['cpus', '--cpus'],
64
65
  ['onKillResumeMemory', '--on-kill-resume-memory'],
65
66
  ]) {
@@ -112,6 +113,9 @@ function buildNextLevelCommand(options, command) {
112
113
  if (options.privileged) {
113
114
  parts.push('--privileged');
114
115
  }
116
+ if (options.init) {
117
+ parts.push('--init');
118
+ }
115
119
  }
116
120
 
117
121
  // Pass through global flags
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const { spawnSync } = require('child_process');
16
+ const { prefetchedInspect } = require('./docker-inspect-batch');
16
17
 
17
18
  /** `containerState` of a record whose container no longer exists. */
18
19
  const CONTAINER_STATE_REMOVED = 'removed';
@@ -50,11 +51,9 @@ function isDockerNotFound(result) {
50
51
  * the inspect failed; `notFound` says the failure was "no such container"
51
52
  */
52
53
  function dockerInspect(dockerCommand, spawnOptions, format, target) {
53
- const result = spawnSync(
54
- dockerCommand,
55
- ['inspect', '-f', format, target],
56
- spawnOptions
57
- );
54
+ const result =
55
+ prefetchedInspect(format, target) ||
56
+ spawnSync(dockerCommand, ['inspect', '-f', format, target], spawnOptions);
58
57
  if (result.error || result.status !== 0 || !result.stdout) {
59
58
  return { stdout: null, notFound: isDockerNotFound(result) };
60
59
  }
@@ -170,6 +170,7 @@ function finalizeDetachedExecution(options = {}) {
170
170
  );
171
171
  record.options.exitEvidence = {
172
172
  daemonRestart: logged.daemonRestart,
173
+ ...evidence.sourceOf(logged),
173
174
  mainOom:
174
175
  !logged.daemonRestart &&
175
176
  (logged.mainOom ||
@@ -12,7 +12,9 @@ function buildCommandHandoffArgs(mainArgs, markerPath) {
12
12
  return [
13
13
  'sh',
14
14
  '-c',
15
- 'p=$1; shift; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
15
+ // `$p.env` refreshes the START_* variables a `docker start` cannot
16
+ // change (issue #230) before either command runs.
17
+ 'p=$1; shift; if [ -f "$p.env" ]; then . "$p.env"; fi; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
16
18
  'start-command-handoff',
17
19
  markerPath,
18
20
  ...mainArgs,
@@ -49,27 +51,53 @@ function buildCommandHandoffScript(command, options = {}) {
49
51
  return `exec ${shellQuote(shell)}${interactive} -c ${shellQuote(effective)}\n`;
50
52
  }
51
53
 
52
- function writeCommandHandoff(containerName, command, runner, options = {}) {
54
+ function copyIntoContainer(containerName, target, content, runner) {
53
55
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-handoff-'));
54
56
  const file = path.join(dir, 'command');
55
57
  try {
56
- fs.writeFileSync(file, buildCommandHandoffScript(command, options), {
57
- mode: 0o644,
58
- });
58
+ fs.writeFileSync(file, content, { mode: 0o644 });
59
59
  fs.chmodSync(file, 0o644);
60
60
  return runner(require('./docker-cleanup').getDockerCommand(), [
61
61
  'cp',
62
62
  file,
63
- `${containerName}:${commandHandoffPath(containerName)}`,
63
+ `${containerName}:${target}`,
64
64
  ]);
65
65
  } finally {
66
66
  fs.rmSync(dir, { recursive: true, force: true });
67
67
  }
68
68
  }
69
69
 
70
+ function writeCommandHandoff(containerName, command, runner, options = {}) {
71
+ return copyIntoContainer(
72
+ containerName,
73
+ commandHandoffPath(containerName),
74
+ buildCommandHandoffScript(command, options),
75
+ runner
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Copy the START_* variables of the next attempt into a stopped container
81
+ * that was created with the handoff selector (issue #230).
82
+ * @param {string} containerName - Container name
83
+ * @param {object} env - Variables from `buildExecutionEnv`
84
+ * @param {Function} runner - Command runner
85
+ * @returns {object} Runner result
86
+ */
87
+ function writeExecutionEnvHandoff(containerName, env, runner) {
88
+ const statement = require('./execution-env').buildExecutionEnvExport(env);
89
+ return copyIntoContainer(
90
+ containerName,
91
+ `${commandHandoffPath(containerName)}.env`,
92
+ statement ? `${statement}\n` : '',
93
+ runner
94
+ );
95
+ }
96
+
70
97
  module.exports = {
71
98
  commandHandoffPath,
72
99
  buildCommandHandoffArgs,
73
100
  buildCommandHandoffScript,
74
101
  writeCommandHandoff,
102
+ writeExecutionEnvHandoff,
75
103
  };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Parse and validate the opt-in init process for docker isolation
3
+ * (issue #244):
4
+ * --init run `docker run --init`, so Docker's bundled tini is PID 1
5
+ * and reaps orphaned processes
6
+ * --no-init keep the command as PID 1, overriding the environment default
7
+ *
8
+ * Without either flag, START_COMMAND_DOCKER_INIT=1 turns it on for every
9
+ * docker launch on the host. The default stays off: a command that is its own
10
+ * supervisor keeps today's behaviour, and recovery works the same for
11
+ * containers started with or without it.
12
+ */
13
+
14
+ const DOCKER_INIT_ENV = 'START_COMMAND_DOCKER_INIT';
15
+
16
+ /**
17
+ * Whether the environment asks for an init process by default.
18
+ * @param {object} [env] - Environment
19
+ * @returns {boolean}
20
+ */
21
+ function dockerInitFromEnv(env = process.env) {
22
+ const value = String(env[DOCKER_INIT_ENV] || '')
23
+ .trim()
24
+ .toLowerCase();
25
+ return ['1', 'true', 'yes', 'on'].includes(value);
26
+ }
27
+
28
+ function parseDockerInitOption(args, index, options) {
29
+ const arg = args[index];
30
+ if (arg === '--init' || arg === '--no-init') {
31
+ options.init = arg === '--init';
32
+ return 1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ /**
38
+ * Resolve `options.init` to a boolean: the flag if one was given (`null`
39
+ * means none), otherwise the environment default for launches whose stack
40
+ * includes docker. Idempotent, because `validateOptions` may run again on
41
+ * options it already resolved: `--no-init` (or a resolved `false`) without
42
+ * docker is the default anyway and is accepted.
43
+ * @param {object} options - Parsed options
44
+ * @param {object} [env] - Environment
45
+ * @throws {Error} If `--init` is given without docker isolation
46
+ */
47
+ function validateDockerInitOption(options, env = process.env) {
48
+ const stack = options.isolatedStack || [options.isolated];
49
+ const hasDocker = options.isolated !== null && stack.includes('docker');
50
+ if (options.init === true && !hasDocker) {
51
+ throw new Error(
52
+ '--init option is only valid when isolation stack includes docker'
53
+ );
54
+ }
55
+ if (typeof options.init !== 'boolean') {
56
+ options.init = hasDocker && dockerInitFromEnv(env);
57
+ }
58
+ }
59
+
60
+ module.exports = {
61
+ DOCKER_INIT_ENV,
62
+ dockerInitFromEnv,
63
+ parse: parseDockerInitOption,
64
+ validate: validateDockerInitOption,
65
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * One `docker inspect` for every container a status query probes (issue #243).
3
+ *
4
+ * `--list` used to start one or two `docker inspect` processes per record
5
+ * (the live state, the process IDs, the removal check), one after another,
6
+ * and `--status` up to three for its one record. While a query runs, this
7
+ * module asks Docker once for every field those probes read, and answers the
8
+ * per-record `docker inspect -f <format> <target>` calls from that answer.
9
+ *
10
+ * Docker prints one line per found container, in argument order, and one
11
+ * `No such container: <target>` line on stderr per missing one. Each line
12
+ * is matched back to its target by name or ID prefix (Docker's own lookup
13
+ * order), so the answer does not depend on the line order. A target the
14
+ * batch cannot account for (an old or stubbed docker, a daemon error) is
15
+ * not answered, and its per-record probe runs as before.
16
+ */
17
+
18
+ const { spawnSync } = require('child_process');
19
+
20
+ /** Fields of the batched inspect, in output order. */
21
+ const BATCH_FIELDS = [
22
+ '.Name',
23
+ '.Id',
24
+ '.State.Pid',
25
+ '.State.Running',
26
+ '.State.ExitCode',
27
+ '.State.OOMKilled',
28
+ '.State.StartedAt',
29
+ '.State.FinishedAt',
30
+ ];
31
+ const BATCH_FORMAT = BATCH_FIELDS.map((field) => `{{${field}}}`).join(' ');
32
+ const NOT_FOUND_LINE = /no such (?:object|container):\s*(\S+)/gi;
33
+ const PLACEHOLDER = /\{\{\s*(\.[A-Za-z.]+)\s*\}\}/g;
34
+
35
+ /** Answers of the query in progress: target -> fields, or null (missing). */
36
+ let prefetched = null;
37
+
38
+ /**
39
+ * Containers the status probes of these records would inspect. Records
40
+ * whose container is already marked removed are skipped: they are never
41
+ * probed (issue #243).
42
+ * @param {Object[]} records - Execution records
43
+ * @returns {string[]} Unique names and IDs
44
+ */
45
+ function dockerProbeTargets(records) {
46
+ const targets = new Set();
47
+ for (const record of records) {
48
+ const opts = (record && record.options) || {};
49
+ if (opts.isolated !== 'docker' || record.containerState === 'removed') {
50
+ continue;
51
+ }
52
+ for (const target of [opts.sessionName, opts.containerId]) {
53
+ if (typeof target === 'string' && target) {
54
+ targets.add(target);
55
+ }
56
+ }
57
+ }
58
+ return [...targets];
59
+ }
60
+
61
+ /**
62
+ * Match a batched inspect answer to its targets.
63
+ * @param {string[]} targets - Names and IDs passed to docker
64
+ * @param {string} stdout - One line of `BATCH_FORMAT` per found container
65
+ * @param {string} stderr - Docker's errors
66
+ * @returns {Map<string, Object|null>} Fields per answered target, null for
67
+ * a target Docker reported missing; unanswered targets are absent
68
+ */
69
+ function parseBatchInspect(targets, stdout, stderr) {
70
+ const rows = String(stdout || '')
71
+ .split('\n')
72
+ .map((line) => line.trim().split(/\s+/))
73
+ .filter((parts) => parts.length === BATCH_FIELDS.length)
74
+ .map((parts) =>
75
+ Object.fromEntries(BATCH_FIELDS.map((field, i) => [field, parts[i]]))
76
+ );
77
+ const missing = new Set(
78
+ [...String(stderr || '').matchAll(NOT_FOUND_LINE)].map((m) => m[1])
79
+ );
80
+ const answers = new Map();
81
+ for (const target of targets) {
82
+ // Docker's own "no such container" wins over a prefix of another
83
+ // target's ID.
84
+ const row = missing.has(target)
85
+ ? null
86
+ : rows.find((r) => r['.Name'] === `/${target}`) ||
87
+ rows.find((r) => r['.Id'].startsWith(target));
88
+ if (row !== undefined) {
89
+ answers.set(target, row);
90
+ }
91
+ }
92
+ return answers;
93
+ }
94
+
95
+ /**
96
+ * Run the batched inspect.
97
+ * @returns {Map<string, Object|null>} See `parseBatchInspect`
98
+ */
99
+ function runBatchInspect(targets, dockerCommand, spawnOptions) {
100
+ if (targets.length === 0) {
101
+ return new Map();
102
+ }
103
+ const result = spawnSync(
104
+ dockerCommand,
105
+ ['inspect', '--type', 'container', '-f', BATCH_FORMAT, ...targets],
106
+ spawnOptions
107
+ );
108
+ if (result.error) {
109
+ return new Map();
110
+ }
111
+ return parseBatchInspect(targets, result.stdout, result.stderr);
112
+ }
113
+
114
+ /**
115
+ * Run `fn` with the docker probes of `records` answered by one inspect.
116
+ * Nested calls reuse the outer answer.
117
+ * @param {Object[]} records - Records the query will probe
118
+ * @param {{dockerCommand: string, spawnOptions: Object}} docker - How to run docker
119
+ * @param {function(): *} fn - The query
120
+ * @returns {*} What `fn` returns
121
+ */
122
+ function withDockerInspectBatch(records, docker, fn) {
123
+ if (prefetched) {
124
+ return fn();
125
+ }
126
+ prefetched = runBatchInspect(
127
+ dockerProbeTargets(records),
128
+ docker.dockerCommand,
129
+ docker.spawnOptions
130
+ );
131
+ try {
132
+ return fn();
133
+ } finally {
134
+ prefetched = null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Answer `docker inspect -f <format> <target>` from the batch.
140
+ * @param {string} format - Go template of the per-record probe
141
+ * @param {string} target - Container name or ID
142
+ * @returns {{success: boolean, status: number, stdout: string,
143
+ * stderr: string}|null} A spawn-like result, or null when the batch has
144
+ * no answer and the probe must run
145
+ */
146
+ function prefetchedInspect(format, target) {
147
+ if (!prefetched || !prefetched.has(target)) {
148
+ return null;
149
+ }
150
+ const row = prefetched.get(target);
151
+ if (row === null) {
152
+ return {
153
+ success: false,
154
+ status: 1,
155
+ stdout: '',
156
+ stderr: `Error response from daemon: No such container: ${target}\n`,
157
+ };
158
+ }
159
+ let known = true;
160
+ const stdout = format.replace(PLACEHOLDER, (_, field) => {
161
+ known = known && field in row;
162
+ return row[field];
163
+ });
164
+ if (!known || stdout.includes('{{')) {
165
+ return null;
166
+ }
167
+ return { success: true, status: 0, stdout: `${stdout}\n`, stderr: '' };
168
+ }
169
+
170
+ module.exports = {
171
+ BATCH_FORMAT,
172
+ dockerProbeTargets,
173
+ parseBatchInspect,
174
+ prefetchedInspect,
175
+ withDockerInspectBatch,
176
+ };
@@ -4,6 +4,7 @@ const { normalizeResourceLimits } = require('./docker-resource-limits');
4
4
  const FIELDS = {
5
5
  '--memory': 'memory',
6
6
  '--memory-swap': 'memorySwap',
7
+ '--memory-reservation': 'memoryReservation',
7
8
  '--cpus': 'cpus',
8
9
  '--on-kill-resume-memory': 'onKillResumeMemory',
9
10
  };
@@ -95,7 +96,7 @@ function readDaemonCapacity(runner) {
95
96
 
96
97
  function resolveResourceOptions(options, runner, random = Math.random) {
97
98
  const specs = {};
98
- for (const field of ['memory', 'memorySwap', 'cpus']) {
99
+ for (const field of ['memory', 'memorySwap', 'memoryReservation', 'cpus']) {
99
100
  if (options[field] !== null && options[field] !== undefined) {
100
101
  specs[field] = parseResourceSpec(options[field], field === 'cpus');
101
102
  }
@@ -137,8 +138,26 @@ function resolveResourceOptions(options, runner, random = Math.random) {
137
138
  ) {
138
139
  throw new Error('memory-swap must be at least memory');
139
140
  }
141
+ // Compare effective values so lowering a cap also checks a saved reservation.
142
+ const existingReservation = limits
143
+ .find((flag) => flag.startsWith('--memory-reservation='))
144
+ ?.slice('--memory-reservation='.length);
145
+ const reservation =
146
+ resolved.memoryReservation ??
147
+ (existingReservation
148
+ ? parseResourceSpec(existingReservation).amount
149
+ : null);
150
+ if (
151
+ memory !== null &&
152
+ memory !== undefined &&
153
+ reservation !== null &&
154
+ reservation !== undefined &&
155
+ reservation >= memory
156
+ ) {
157
+ throw new Error('memory-reservation must be less than memory');
158
+ }
140
159
  for (const [field, amount] of Object.entries(resolved)) {
141
- const flag = field === 'memorySwap' ? '--memory-swap' : `--${field}`;
160
+ const flag = Object.keys(FIELDS).find((key) => FIELDS[key] === field);
142
161
  for (let i = limits.length - 1; i >= 0; i--) {
143
162
  if (
144
163
  limits[i].startsWith(`${flag}=`) ||
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Docker runtime options shared by `docker run`, `[Isolation]` status lines
3
- * and execution-record metadata: privileged mode, environment, volumes,
4
- * mounts, networks (issue #150) and resource limits (issue #176).
3
+ * and execution-record metadata: privileged mode, the init process
4
+ * (issue #244), environment, volumes, mounts, networks (issue #150) and
5
+ * resource limits (issue #176).
5
6
  */
6
7
 
7
8
  const { getDockerNetworks } = require('./docker-network-lifecycle');
@@ -31,7 +32,7 @@ function getOnKillResume(options = {}) {
31
32
  * mounts, --mount specs, network configuration and resource limits. Returned
32
33
  * in a stable order so they can be spliced into the `docker run` argv before
33
34
  * the image name.
34
- * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases, resourceLimits)
35
+ * @param {object} options - Options (privileged, init, env, volumes, mounts, network, networkAliases, resourceLimits)
35
36
  * @returns {string[]} Docker CLI arguments
36
37
  */
37
38
  function buildDockerRuntimeArgs(options = {}) {
@@ -39,9 +40,20 @@ function buildDockerRuntimeArgs(options = {}) {
39
40
  if (options.privileged) {
40
41
  args.push('--privileged');
41
42
  }
43
+ // tini as PID 1 reaps orphaned processes (issue #244).
44
+ if (options.init) {
45
+ args.push('--init');
46
+ }
42
47
  for (const envVar of options.env || []) {
43
48
  args.push('-e', envVar);
44
49
  }
50
+ // Execution identity (issue #230); last, so a user `--env` cannot spoof it.
51
+ const executionEnv = require('./execution-env');
52
+ for (const envVar of executionEnv.executionEnvAssignments(
53
+ executionEnv.executionEnvFromOptions(options)
54
+ )) {
55
+ args.push('-e', envVar);
56
+ }
45
57
  for (const label of require('./docker-labels').dockerLabels(options)) {
46
58
  args.push('--label', label);
47
59
  }
@@ -87,6 +99,9 @@ function buildDockerRuntimeStatusLines(options = {}) {
87
99
  if (options.privileged) {
88
100
  lines.push(`[Isolation] Privileged: true`);
89
101
  }
102
+ if (options.init) {
103
+ lines.push(`[Isolation] Init: true`);
104
+ }
90
105
  const networks = getDockerNetworks(options);
91
106
  if (networks.length > 0) {
92
107
  lines.push(`[Isolation] Network: ${networks[0]}`);
@@ -138,6 +153,7 @@ function buildDockerRuntimeMetadata(options = {}) {
138
153
  labels: options.labels && options.labels.length > 0 ? options.labels : null,
139
154
  commandHandoff: options.detached ? true : null,
140
155
  privileged: options.privileged || null,
156
+ init: options.init || null,
141
157
  network: networks[0] || null,
142
158
  networks: networks.length > 0 ? networks : null,
143
159
  networkAliases:
@@ -46,6 +46,21 @@ function readAttemptActivity(record) {
46
46
  return record;
47
47
  }
48
48
 
49
+ /** Number of the record's current attempt: 1 until it is resumed. */
50
+ function currentAttemptNumber(record) {
51
+ return (
52
+ record?.attempt?.number ||
53
+ (Number(record?.options?.resumeCount) || 0) +
54
+ (Number(record?.options?.recoveryAttempts) || 0) +
55
+ 1
56
+ );
57
+ }
58
+
59
+ /** Number the next attempt of `record` gets (`START_ATTEMPT`, issue #230). */
60
+ function nextAttemptNumber(record) {
61
+ return currentAttemptNumber(record) + 1;
62
+ }
63
+
49
64
  function createAttempt(record, plan) {
50
65
  let logOffset = null;
51
66
  if (record.logPath) {
@@ -58,11 +73,7 @@ function createAttempt(record, plan) {
58
73
  }
59
74
  }
60
75
  return {
61
- number:
62
- (record.attempt?.number ||
63
- (Number(record.options?.resumeCount) || 0) +
64
- (Number(record.options?.recoveryAttempts) || 0) +
65
- 1) + 1,
76
+ number: nextAttemptNumber(record),
66
77
  startedAt: new Date().toISOString(),
67
78
  logOffset,
68
79
  lastOutputAt: null,
@@ -78,10 +89,7 @@ function archiveAttempt(record) {
78
89
  readAttemptActivity(record);
79
90
  const snapshot = {
80
91
  ...(record.attempt || {
81
- number:
82
- (Number(record.options?.resumeCount) || 0) +
83
- (Number(record.options?.recoveryAttempts) || 0) +
84
- 1,
92
+ number: currentAttemptNumber(record),
85
93
  startedAt: record.options?.resumedAt || record.startTime,
86
94
  logOffset: null,
87
95
  }),
@@ -176,6 +184,8 @@ module.exports = {
176
184
  appendLifecycle,
177
185
  archiveAttempt,
178
186
  createAttempt,
187
+ currentAttemptNumber,
188
+ nextAttemptNumber,
179
189
  patchAttempt,
180
190
  readAttemptActivity,
181
191
  readAttemptLogTail,