start-command 0.37.9 → 0.38.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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * START_* variables that tell a launched command which execution it belongs
3
+ * to (issue #230). Every backend exports the same three variables, next to
4
+ * the kill-recovery `START_COMMAND_RECOVERY_ATTEMPT`:
5
+ *
6
+ * - START_EXECUTION_ID: the execution UUID (`--session-id`)
7
+ * - START_SESSION_NAME: the isolation session/container name (`--session`);
8
+ * empty for direct runs, which have no session
9
+ * - START_ATTEMPT: 1 for the first run, n for the n-th attempt (`--resume`,
10
+ * kill recovery or `--append-attempt`), the same number `--status` shows
11
+ */
12
+ const { shellQuote } = require('./isolation-log-utils');
13
+
14
+ const EXECUTION_ID_ENV = 'START_EXECUTION_ID';
15
+ const SESSION_NAME_ENV = 'START_SESSION_NAME';
16
+ const ATTEMPT_ENV = 'START_ATTEMPT';
17
+
18
+ /**
19
+ * Build the START_* variables for one attempt.
20
+ * @param {object} params - {executionId, sessionName, attempt}
21
+ * @returns {object} Variables in a stable order, or {} without an execution id
22
+ */
23
+ function buildExecutionEnv({ executionId, sessionName, attempt } = {}) {
24
+ if (!executionId) {
25
+ return {};
26
+ }
27
+ const number = Number(attempt);
28
+ return {
29
+ [EXECUTION_ID_ENV]: String(executionId),
30
+ [SESSION_NAME_ENV]: sessionName ? String(sessionName) : '',
31
+ [ATTEMPT_ENV]: String(
32
+ Number.isSafeInteger(number) && number > 0 ? number : 1
33
+ ),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Build the variables from isolation options (`runIsolated`, `docker run`).
39
+ * @param {object} options - {sessionId|executionId, containerName|session, attemptNumber}
40
+ * @returns {object} Variables, or {}
41
+ */
42
+ function executionEnvFromOptions(options = {}) {
43
+ return buildExecutionEnv({
44
+ executionId: options.sessionId || options.executionId,
45
+ sessionName: options.containerName || options.session,
46
+ attempt: options.attemptNumber,
47
+ });
48
+ }
49
+
50
+ /**
51
+ * `KEY=value` assignments, e.g. for `docker run -e`.
52
+ * @param {object} env - Variables
53
+ * @returns {string[]} Assignments
54
+ */
55
+ function executionEnvAssignments(env = {}) {
56
+ return Object.entries(env).map(([key, value]) => `${key}=${value}`);
57
+ }
58
+
59
+ /**
60
+ * POSIX `export` statement for the variables, without a trailing separator.
61
+ * @param {object} env - Variables
62
+ * @returns {string} Statement, or '' when there is nothing to export
63
+ */
64
+ function buildExecutionEnvExport(env = {}) {
65
+ const entries = Object.entries(env);
66
+ if (!entries.length) {
67
+ return '';
68
+ }
69
+ return `export ${entries
70
+ .map(([key, value]) => `${key}=${shellQuote(value)}`)
71
+ .join(' ')}`;
72
+ }
73
+
74
+ /**
75
+ * Prefix a shell command with the export statement. Used by the backends
76
+ * whose command runs through a shell that does not inherit our environment:
77
+ * a running tmux server, ssh, and `sudo` (which resets the environment).
78
+ * @param {string} command - Shell command
79
+ * @param {object} env - Variables
80
+ * @returns {string} Prefixed command
81
+ */
82
+ function prefixWithExecutionEnv(command, env = {}) {
83
+ const statement = buildExecutionEnvExport(env);
84
+ return statement ? `${statement}; ${command}` : command;
85
+ }
86
+
87
+ /**
88
+ * Child-process environment with the variables applied.
89
+ * @param {object} env - Variables
90
+ * @param {object} [base] - Base environment (defaults to process.env)
91
+ * @returns {object} Environment
92
+ */
93
+ function withExecutionEnv(env = {}, base = process.env) {
94
+ return { ...base, ...env };
95
+ }
96
+
97
+ /**
98
+ * True when this process was itself started by execution `executionId`, i.e.
99
+ * it is the next level of a stacked isolation that passes `--session-id`
100
+ * down (see command-builder.js).
101
+ * @param {string} executionId - Execution UUID
102
+ * @param {object} [env] - Environment to inspect
103
+ * @returns {boolean}
104
+ */
105
+ function isNestedLaunchOf(executionId, env = process.env) {
106
+ return Boolean(executionId) && env[EXECUTION_ID_ENV] === executionId;
107
+ }
108
+
109
+ module.exports = {
110
+ EXECUTION_ID_ENV,
111
+ SESSION_NAME_ENV,
112
+ ATTEMPT_ENV,
113
+ buildExecutionEnv,
114
+ executionEnvFromOptions,
115
+ executionEnvAssignments,
116
+ buildExecutionEnvExport,
117
+ prefixWithExecutionEnv,
118
+ withExecutionEnv,
119
+ isNestedLaunchOf,
120
+ };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Names an execution answers to (issue #231).
3
+ *
4
+ * An execution is found by its UUID, its backend session name, the session
5
+ * names it used before a resume (`options.sessionNameHistory`, #162), its
6
+ * display name (`options.name`, set by `--rename`) and its aliases
7
+ * (`options.aliases`, added by `--alias` and `--rename`). Aliases are kept
8
+ * apart from `sessionNameHistory` because that list names real backend
9
+ * sessions: `--cleanup` removes those containers and `--resume` derives the
10
+ * root session from it.
11
+ */
12
+
13
+ function hasName(record, identifier) {
14
+ const options = record.options || {};
15
+ return (
16
+ options.name === identifier ||
17
+ (Array.isArray(options.aliases) && options.aliases.includes(identifier))
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Resolve an identifier among stored records: UUID, then current session
23
+ * name, then earlier session names, then display name and aliases.
24
+ * @param {object[]} records - Stored records
25
+ * @param {string} identifier - Any name of the execution
26
+ * @returns {object|null} The record, or null
27
+ */
28
+ function findRecord(records, identifier) {
29
+ if (identifier === null || identifier === undefined || identifier === '') {
30
+ return null;
31
+ }
32
+ return (
33
+ records.find((r) => r.uuid === identifier) ||
34
+ records.find((r) => r.options && r.options.sessionName === identifier) ||
35
+ records.find(
36
+ (r) =>
37
+ r.options &&
38
+ Array.isArray(r.options.sessionNameHistory) &&
39
+ r.options.sessionNameHistory.includes(identifier)
40
+ ) ||
41
+ records.find((r) => hasName(r, identifier)) ||
42
+ null
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Every name the record answers to, UUID first, without duplicates.
48
+ * @param {object} record - Execution record
49
+ * @returns {string[]} Names
50
+ */
51
+ function executionAliases(record) {
52
+ const options = record.options || {};
53
+ const names = [
54
+ record.uuid,
55
+ ...(options.sessionNameHistory || []),
56
+ options.sessionName,
57
+ ...(options.aliases || []),
58
+ options.name,
59
+ ].filter((name) => typeof name === 'string' && name);
60
+ return [...new Set(names)];
61
+ }
62
+
63
+ /** True once the record has a caller-given name (`--alias`/`--rename`). */
64
+ function hasUserNames(record) {
65
+ const options = record.options || {};
66
+ return Boolean(options.name) || (options.aliases || []).length > 0;
67
+ }
68
+
69
+ /**
70
+ * Run `mutate(records)` under the store's database lock and persist the
71
+ * records it returns as changed.
72
+ * @param {object} store - ExecutionStore
73
+ * @param {Function} mutate - (records) => changed record, or null for none
74
+ * @returns {*} What `mutate` returned
75
+ */
76
+ function withStoreLock(store, mutate) {
77
+ const { LockManager } = require('./execution-store');
78
+ const lock = new LockManager(store.lockFilePath);
79
+ if (!lock.acquire()) {
80
+ throw new Error('Failed to acquire lock for database write');
81
+ }
82
+ try {
83
+ const records = store.readLinoRecords();
84
+ const changed = mutate(records);
85
+ if (changed) {
86
+ store.writeLinoRecords(records);
87
+ if (store.useLinks) {
88
+ store.writeLinksRecord(changed);
89
+ }
90
+ }
91
+ return changed;
92
+ } finally {
93
+ lock.release();
94
+ }
95
+ }
96
+
97
+ function validateName(name) {
98
+ if (typeof name !== 'string' || !name.trim()) {
99
+ throw new Error('The new name must not be empty');
100
+ }
101
+ if (name.startsWith('-')) {
102
+ throw new Error(`Invalid name "${name}": names cannot start with "-"`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Add a name to an execution (`$ --alias <id> <name>`) or rename it
108
+ * (`$ --rename <id> <name>`, which keeps the previous display name as an
109
+ * alias). The backend session/container keeps its name; only lookup and the
110
+ * displayed name change.
111
+ * @param {object} store - ExecutionStore
112
+ * @param {string} identifier - Any current name of the execution
113
+ * @param {string} name - Name to add
114
+ * @param {object} [options] - {rename: boolean}
115
+ * @returns {{success: boolean, output?: string, error?: string}}
116
+ */
117
+ function nameExecution(store, identifier, name, options = {}) {
118
+ if (!store) {
119
+ return { success: false, error: 'Execution tracking is disabled.' };
120
+ }
121
+ try {
122
+ validateName(name);
123
+ let unchanged = false;
124
+ const record = withStoreLock(store, (records) => {
125
+ const target = findRecord(records, identifier);
126
+ if (!target) {
127
+ throw new Error(
128
+ `No execution found with UUID or session name: ${identifier}`
129
+ );
130
+ }
131
+ const owner = findRecord(records, name);
132
+ if (owner && owner.uuid !== target.uuid) {
133
+ throw new Error(
134
+ `Name "${name}" already refers to execution ${owner.uuid}`
135
+ );
136
+ }
137
+ const opts = (target.options = target.options || {});
138
+ const aliases = [...(opts.aliases || [])];
139
+ if (options.rename) {
140
+ const previous = opts.name || opts.sessionName || target.uuid;
141
+ if (previous === name) {
142
+ unchanged = true;
143
+ return null;
144
+ }
145
+ // A session name or UUID still resolves on its own; an earlier
146
+ // display name has to become an alias to keep resolving.
147
+ if (opts.name) {
148
+ aliases.push(previous);
149
+ }
150
+ opts.name = name;
151
+ } else if (owner) {
152
+ unchanged = true;
153
+ return null;
154
+ } else {
155
+ aliases.push(name);
156
+ }
157
+ opts.aliases = [...new Set(aliases)].filter((a) => a !== opts.name);
158
+ if (opts.aliases.length === 0) {
159
+ delete opts.aliases;
160
+ }
161
+ return target;
162
+ });
163
+ const current = record || store.get(identifier);
164
+ const action = options.rename ? 'Renamed' : 'Aliased';
165
+ return {
166
+ success: true,
167
+ output: `${unchanged ? 'Unchanged' : action} execution ${current.uuid}\naliases: [${executionAliases(current).join(', ')}]`,
168
+ };
169
+ } catch (error) {
170
+ return { success: false, error: error.message };
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ executionAliases,
176
+ findRecord,
177
+ hasUserNames,
178
+ nameExecution,
179
+ withStoreLock,
180
+ };
@@ -379,6 +379,23 @@ function recoverKilledExecution(params = {}) {
379
379
  }
380
380
  }
381
381
 
382
+ if (opts.commandHandoff && record.uuid) {
383
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
384
+ try {
385
+ require('./docker-command-handoff').writeExecutionEnvHandoff(
386
+ containerName,
387
+ require('./execution-env').buildExecutionEnv({
388
+ executionId: record.uuid,
389
+ sessionName: containerName,
390
+ attempt: require('./execution-attempt').nextAttemptNumber(record),
391
+ }),
392
+ runner
393
+ );
394
+ } catch {
395
+ // Non-fatal: the command still runs, with the previous START_ATTEMPT.
396
+ }
397
+ }
398
+
382
399
  const since = now().toISOString();
383
400
  const {
384
401
  appendLifecycle,
@@ -34,6 +34,7 @@ const {
34
34
  appendLifecycle,
35
35
  archiveAttempt,
36
36
  createAttempt,
37
+ nextAttemptNumber,
37
38
  patchAttempt,
38
39
  } = require('./execution-attempt');
39
40
  const { runCommand } = require('./execution-control');
@@ -46,6 +47,7 @@ const { SessionState, probeSession } = require('./session-probe');
46
47
  const { getDockerNetworks } = require('./docker-network-lifecycle');
47
48
  const {
48
49
  writeCommandHandoff,
50
+ writeExecutionEnvHandoff,
49
51
  buildCommandHandoffArgs,
50
52
  commandHandoffPath,
51
53
  } = require('./docker-command-handoff');
@@ -110,6 +112,8 @@ function buildLaunchOptions(record) {
110
112
  rootSession:
111
113
  opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
112
114
  resumeCount: (Number(opts.resumeCount) || 0) + 1,
115
+ // START_ATTEMPT of the relaunched command (issue #230).
116
+ attemptNumber: nextAttemptNumber(record),
113
117
  image: opts.image || null,
114
118
  session: opts.sessionName,
115
119
  detached: true,
@@ -289,6 +293,7 @@ function buildResumePlan(
289
293
  session: newSessionName,
290
294
  containerName: newSessionName,
291
295
  sessionId: record.uuid,
296
+ attemptNumber: nextAttemptNumber(record),
292
297
  rootSession:
293
298
  opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
294
299
  resumeCount: attempt,
@@ -657,6 +662,18 @@ async function resumeExecution(store, identifier, deps = {}) {
657
662
  );
658
663
  }
659
664
  }
665
+ if (plan.mode === ResumeMode.DOCKER_START && opts.commandHandoff) {
666
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
667
+ writeExecutionEnvHandoff(
668
+ plan.sessionName,
669
+ require('./execution-env').buildExecutionEnv({
670
+ executionId: record.uuid,
671
+ sessionName: plan.sessionName,
672
+ attempt: attempt.number,
673
+ }),
674
+ runner
675
+ );
676
+ }
660
677
  for (const step of plan.steps) {
661
678
  let result;
662
679
  try {
@@ -77,6 +77,8 @@ class ExecutionRecord {
77
77
  this.endTime = options.endTime || null;
78
78
  this.attempt = options.attempt;
79
79
  this.attemptHistory = options.attemptHistory;
80
+ // UUID of the execution this one continues (`--continues`, issue #231).
81
+ this.continues = options.continues || undefined;
80
82
  // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
81
83
  // finish time with `new Date()` whenever the record had none, which made an
82
84
  // observation indistinguishable from a real finish time. One of
@@ -172,6 +174,9 @@ class ExecutionRecord {
172
174
  if (this.attemptHistory) {
173
175
  obj.attemptHistory = this.attemptHistory;
174
176
  }
177
+ if (this.continues) {
178
+ obj.continues = this.continues;
179
+ }
175
180
  if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
176
181
  obj.endTimeSource = this.endTimeSource;
177
182
  }
@@ -544,33 +549,17 @@ class ExecutionStore {
544
549
  /**
545
550
  * Get an execution record by UUID or session name
546
551
  * First tries exact UUID match, then the current session name, then any
547
- * session name the execution used before it was resumed
552
+ * session name the execution used before it was resumed, then the display
553
+ * name and aliases
548
554
  * @param {string} identifier - UUID or session name
549
555
  * @returns {ExecutionRecord|null}
550
556
  */
551
557
  get(identifier) {
552
- const records = this.readLinoRecords();
553
- // First try exact UUID match
554
- const byUuid = records.find((r) => r.uuid === identifier);
555
- if (byUuid) {
556
- return byUuid;
557
- }
558
- // Fall back to session name lookup (stored in options.sessionName)
559
- const bySessionName = records.find(
560
- (r) => r.options && r.options.sessionName === identifier
561
- );
562
- if (bySessionName) {
563
- return bySessionName;
564
- }
565
- // Finally, honor session names this record used before it was resumed, so
566
- // one logical session stays addressable across restarts (issue #162).
567
- const byPreviousSessionName = records.find(
568
- (r) =>
569
- r.options &&
570
- Array.isArray(r.options.sessionNameHistory) &&
571
- r.options.sessionNameHistory.includes(identifier)
558
+ // Names, display names and aliases resolve too (issues #162, #231).
559
+ return require('./execution-identity').findRecord(
560
+ this.readLinoRecords(),
561
+ identifier
572
562
  );
573
- return byPreviousSessionName || null;
574
563
  }
575
564
 
576
565
  /**
@@ -201,7 +201,8 @@ function runScreenWithLogCapture(
201
201
  sessionName,
202
202
  shellInfo,
203
203
  user = null,
204
- logPath = null
204
+ logPath = null,
205
+ env = null
205
206
  ) {
206
207
  return _runScreenWithLogCapture(
207
208
  command,
@@ -210,7 +211,8 @@ function runScreenWithLogCapture(
210
211
  user,
211
212
  wrapCommandWithUser,
212
213
  isInteractiveShellCommand,
213
- logPath
214
+ logPath,
215
+ env
214
216
  );
215
217
  }
216
218
 
@@ -241,6 +243,7 @@ function runInScreen(command, options = {}) {
241
243
  keepAlive: options.keepAlive,
242
244
  logPath: options.logPath,
243
245
  wrapCommandWithUser,
246
+ env: options.executionEnv,
244
247
  })
245
248
  );
246
249
  } else {
@@ -249,7 +252,8 @@ function runInScreen(command, options = {}) {
249
252
  sessionName,
250
253
  shellInfo,
251
254
  options.user,
252
- options.logPath
255
+ options.logPath,
256
+ options.executionEnv
253
257
  );
254
258
  }
255
259
  } catch (err) {
@@ -909,6 +913,20 @@ function runIsolated(backend, command, options = {}) {
909
913
  session: options.sessionStack ? options.sessionStack[0] : options.session,
910
914
  logPath: options.logPath,
911
915
  };
916
+ // START_* identity (issue #230): docker passes it with `-e`; a bare screen
917
+ // shell inherits the spawn env; everything else gets an `export` prefix.
918
+ const identity = require('./execution-env');
919
+ currentOptions.executionEnv =
920
+ identity.executionEnvFromOptions(currentOptions);
921
+ if (
922
+ backend !== 'docker' &&
923
+ !(backend === 'screen' && isInteractiveShellCommand(effectiveCommand))
924
+ ) {
925
+ effectiveCommand = identity.prefixWithExecutionEnv(
926
+ effectiveCommand,
927
+ currentOptions.executionEnv
928
+ );
929
+ }
912
930
 
913
931
  switch (backend) {
914
932
  case 'screen':
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Launch options about the identity of a new execution:
3
+ * --append-attempt run as the next attempt of the tracked execution named
4
+ * by --session-id instead of being refused (issue #229)
5
+ * --continues <id> record that this execution continues an earlier one,
6
+ * which `--status` and `--upload-log` follow (issue #231)
7
+ *
8
+ * Both only apply when a command is launched, not to the query modes.
9
+ */
10
+ const { activeQueryModes } = require('./args-parser-queries');
11
+
12
+ function createLaunchIdentityOptionDefaults() {
13
+ return {
14
+ appendAttempt: false,
15
+ continues: null,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Parse one launch identity option.
21
+ * @param {string[]} args - Arguments array
22
+ * @param {number} index - Current index
23
+ * @param {object} options - Options object to populate
24
+ * @returns {number} Number of arguments consumed (0 if not recognized)
25
+ */
26
+ function parse(args, index, options) {
27
+ const arg = args[index];
28
+ if (arg === '--append-attempt') {
29
+ options.appendAttempt = true;
30
+ return 1;
31
+ }
32
+ if (arg === '--continues') {
33
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
34
+ options.continues = args[index + 1];
35
+ return 2;
36
+ }
37
+ throw new Error(
38
+ 'Option --continues requires a UUID or session name argument'
39
+ );
40
+ }
41
+ if (arg.startsWith('--continues=')) {
42
+ const value = arg.slice('--continues='.length);
43
+ if (!value) {
44
+ throw new Error(
45
+ 'Option --continues requires a UUID or session name argument'
46
+ );
47
+ }
48
+ options.continues = value;
49
+ return 1;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ /**
55
+ * Validate the launch identity options.
56
+ * @param {object} options - Parsed wrapper options
57
+ * @throws {Error} When the combination is not supported
58
+ */
59
+ function validate(options) {
60
+ const used = [
61
+ ...(options.appendAttempt ? ['--append-attempt'] : []),
62
+ ...(options.continues !== null && options.continues !== undefined
63
+ ? ['--continues']
64
+ : []),
65
+ ];
66
+ if (used.length === 0) {
67
+ return;
68
+ }
69
+ if (activeQueryModes(options).length > 0) {
70
+ throw new Error(`${used[0]} option is only valid when launching a command`);
71
+ }
72
+ if (options.appendAttempt && !options.sessionId) {
73
+ throw new Error('--append-attempt option requires --session-id <uuid>');
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ createLaunchIdentityOptionDefaults,
79
+ parse,
80
+ validate,
81
+ };