start-command 0.37.8 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +66 -26
  5. package/src/lib/args-parser-queries.js +90 -9
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/container-removal.js +163 -0
  8. package/src/lib/detached-finalize.js +29 -2
  9. package/src/lib/docker-cleanup.js +7 -4
  10. package/src/lib/docker-command-handoff.js +34 -6
  11. package/src/lib/docker-post-mortem.js +2 -0
  12. package/src/lib/docker-runtime-args.js +7 -0
  13. package/src/lib/docker-snapshot-resume.js +222 -0
  14. package/src/lib/docker-snapshot-safety.js +124 -12
  15. package/src/lib/execution-attempt.js +21 -9
  16. package/src/lib/execution-chain.js +182 -0
  17. package/src/lib/execution-cleanup.js +193 -0
  18. package/src/lib/execution-control.js +2 -1
  19. package/src/lib/execution-env.js +120 -0
  20. package/src/lib/execution-identity.js +180 -0
  21. package/src/lib/execution-recovery.js +19 -0
  22. package/src/lib/execution-resume.js +77 -6
  23. package/src/lib/execution-store.js +63 -22
  24. package/src/lib/isolation.js +25 -3
  25. package/src/lib/launch-identity-options.js +81 -0
  26. package/src/lib/launch-identity.js +411 -0
  27. package/src/lib/log-uploader.js +15 -1
  28. package/src/lib/query-commands.js +34 -6
  29. package/src/lib/screen-isolation.js +11 -2
  30. package/src/lib/snapshot-resume-options.js +174 -0
  31. package/src/lib/spawn-helpers.js +33 -16
  32. package/src/lib/status-formatter.js +64 -35
  33. package/src/lib/status-links-notation.js +97 -0
  34. package/src/lib/store-lock.js +3 -0
  35. package/src/lib/usage.js +15 -1
  36. package/test/args-parser-attach-resume.js +1 -1
  37. package/test/args-parser-control.js +2 -2
  38. package/test/regression-224.js +807 -0
  39. package/test/regression-225.js +415 -0
  40. package/test/regression-226.js +81 -0
  41. package/test/regression-229.js +267 -0
  42. package/test/regression-230.js +234 -0
  43. package/test/regression-231.js +298 -0
  44. package/test/shared-golden.js +35 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Continuation chains (issue #231): a launch with `--continues <id>` stores
3
+ * the UUID it continues, so a supervisor that relaunches fresh instead of
4
+ * `--resume`-ing in place can still treat every execution of one task as a
5
+ * whole. `--status` prints the chain and `--upload-log` uploads one file with
6
+ * every execution of it, oldest first.
7
+ */
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { executionAliases, hasUserNames } = require('./execution-identity');
12
+
13
+ /**
14
+ * UUIDs of the chain `record` belongs to, oldest first. Backward links come
15
+ * from `continues`; forward, the most recently started record that continues
16
+ * the current one is followed. Cycles stop the walk.
17
+ * @param {object[]} records - Stored records
18
+ * @param {object} record - Any member of the chain
19
+ * @returns {object[]} Records of the chain, oldest first
20
+ */
21
+ function buildChain(records, record) {
22
+ const seen = new Set([record.uuid]);
23
+ const chain = [record];
24
+ for (let cur = record; cur.continues; ) {
25
+ const prev = records.find((r) => r.uuid === cur.continues);
26
+ if (!prev || seen.has(prev.uuid)) {
27
+ break;
28
+ }
29
+ seen.add(prev.uuid);
30
+ chain.unshift(prev);
31
+ cur = prev;
32
+ }
33
+ for (let cur = record; ; ) {
34
+ const next = records
35
+ .filter((r) => r.continues === cur.uuid && !seen.has(r.uuid))
36
+ .sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime))[0];
37
+ if (!next) {
38
+ break;
39
+ }
40
+ seen.add(next.uuid);
41
+ chain.push(next);
42
+ cur = next;
43
+ }
44
+ return chain;
45
+ }
46
+
47
+ /**
48
+ * Resolve `--continues <id>` for a new launch to the UUID it stores.
49
+ * @param {object|null} store - ExecutionStore
50
+ * @param {string} identifier - Any name of the earlier execution
51
+ * @param {string} sessionId - UUID of the new launch
52
+ * @returns {string} UUID of the continued execution
53
+ * @throws {Error} When it cannot be resolved
54
+ */
55
+ function resolveContinues(store, identifier, sessionId) {
56
+ if (!store) {
57
+ throw new Error('--continues requires execution tracking.');
58
+ }
59
+ const target = store.get(identifier);
60
+ if (!target) {
61
+ throw new Error(
62
+ `No execution found with UUID or session name: ${identifier}`
63
+ );
64
+ }
65
+ if (target.uuid === sessionId) {
66
+ throw new Error(`Execution ${sessionId} cannot continue itself`);
67
+ }
68
+ return target.uuid;
69
+ }
70
+
71
+ /**
72
+ * Wrap a status record so `toObject()` also reports the names it answers to
73
+ * (`aliases`, once `--alias`/`--rename` gave it one) and its continuation
74
+ * `chain`, right after `uuid`.
75
+ * @param {object} record - Record-like object with toObject()
76
+ * @param {object|null} store - ExecutionStore
77
+ * @returns {object} The record, or a wrapper with the extra fields
78
+ */
79
+ function attachIdentity(record, store) {
80
+ const aliases = hasUserNames(record) ? executionAliases(record) : null;
81
+ const chain =
82
+ store &&
83
+ (record.continues ||
84
+ store.getAll().some((r) => r.continues === record.uuid))
85
+ ? buildChain(store.getAll(), record).map((r) => r.uuid)
86
+ : null;
87
+ if (!aliases && !(chain && chain.length > 1)) {
88
+ return record;
89
+ }
90
+ const wrapped = Object.create(Object.getPrototypeOf(record));
91
+ Object.assign(wrapped, record);
92
+ const source = record.toObject.bind(record);
93
+ wrapped.toObject = function () {
94
+ const ordered = {};
95
+ for (const [key, value] of Object.entries(source())) {
96
+ ordered[key] = value;
97
+ if (key === 'uuid') {
98
+ if (aliases) {
99
+ ordered.aliases = aliases;
100
+ }
101
+ if (chain && chain.length > 1) {
102
+ ordered.chain = chain;
103
+ }
104
+ }
105
+ }
106
+ return ordered;
107
+ };
108
+ return wrapped;
109
+ }
110
+
111
+ /**
112
+ * Text-format lines for the identity fields of a status object.
113
+ * @param {object} obj - Output of toObject()
114
+ * @returns {string[]} Lines
115
+ */
116
+ function identityTextLines(obj) {
117
+ return [
118
+ ...(obj.aliases ? [`Aliases: ${obj.aliases.join(', ')}`] : []),
119
+ ...(obj.continues ? [`Continues: ${obj.continues}`] : []),
120
+ ...(obj.chain ? [`Chain: ${obj.chain.join(' -> ')}`] : []),
121
+ ];
122
+ }
123
+
124
+ /**
125
+ * Concatenate the logs of a chain into one temporary file, oldest first,
126
+ * each preceded by a lifecycle header in the format `--resume` writes.
127
+ * @param {object[]} chain - Records, oldest first
128
+ * @returns {{path: string, cleanup: Function}} Combined log
129
+ */
130
+ function writeChainLog(chain) {
131
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-chain-log-'));
132
+ const file = path.join(dir, `${chain[chain.length - 1].uuid}.chain.log`);
133
+ chain.forEach((record, index) => {
134
+ const header = {
135
+ event: 'continuation',
136
+ uuid: record.uuid,
137
+ chainIndex: index + 1,
138
+ chainLength: chain.length,
139
+ continues: record.continues || null,
140
+ command: record.command,
141
+ startTime: record.startTime,
142
+ logPath: record.logPath || null,
143
+ };
144
+ fs.appendFileSync(
145
+ file,
146
+ `\n[Start Command Lifecycle] ${JSON.stringify(header)}\n`
147
+ );
148
+ if (record.logPath && fs.existsSync(record.logPath)) {
149
+ fs.appendFileSync(file, fs.readFileSync(record.logPath));
150
+ } else {
151
+ fs.appendFileSync(file, `Log file not found: ${record.logPath}\n`);
152
+ }
153
+ });
154
+ return {
155
+ path: file,
156
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Log path `--upload-log` should upload for `record`: its own log, or a
162
+ * combined log when it belongs to a chain of more than one execution.
163
+ * @param {object} store - ExecutionStore
164
+ * @param {object} record - Resolved record
165
+ * @returns {{path: string, cleanup: Function}} Log to upload
166
+ */
167
+ function prepareUploadLog(store, record) {
168
+ const chain = buildChain(store.getAll(), record);
169
+ if (chain.length < 2) {
170
+ return { path: record.logPath, cleanup: () => {} };
171
+ }
172
+ return writeChainLog(chain);
173
+ }
174
+
175
+ module.exports = {
176
+ attachIdentity,
177
+ buildChain,
178
+ identityTextLines,
179
+ prepareUploadLog,
180
+ resolveContinues,
181
+ writeChainLog,
182
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `$ --cleanup <uuid-or-session-name>` (issue #224): remove the Docker
3
+ * containers of one execution and the snapshot images they were derived from.
4
+ *
5
+ * For callers that remove containers themselves: start-command's completion
6
+ * watcher removes a snapshot image only when it removes the container. Nothing
7
+ * here is forced, so Docker keeps any container that is still running and any
8
+ * image that something else still uses; those are reported as retained.
9
+ *
10
+ * The containers are the current one and every earlier container of a
11
+ * `--resume` chain (`sessionNameHistory`). The images are the
12
+ * `start-command-resume/*` images named by the `start-command.snapshot-image`
13
+ * label of those containers, by the record, and by the `-resume-N` names of
14
+ * the chain, removed newest first.
15
+ */
16
+ const { getDockerCommand } = require('./docker-cleanup');
17
+ const { runCommand } = require('./execution-control');
18
+ const { SNAPSHOT_IMAGE_LABEL } = require('./docker-snapshot-safety');
19
+
20
+ const SNAPSHOT_IMAGE_PATTERN = /^start-command-resume\/[a-z0-9._-]+:\d+$/;
21
+ const NOT_FOUND_PATTERN = /no such (container|image|object)/i;
22
+
23
+ /** Docker image name for snapshot attempt `attempt` of `sessionName`. */
24
+ function snapshotImageName(sessionName, attempt) {
25
+ const { buildSnapshotImageName } = require('./execution-resume');
26
+ return buildSnapshotImageName(sessionName, attempt);
27
+ }
28
+
29
+ /** Snapshot images implied by a `<name>-resume-<N>` chain, newest first. */
30
+ function chainImages(containerName) {
31
+ const images = [];
32
+ let current = String(containerName || '');
33
+ for (
34
+ let match = /^(.+)-resume-(\d+)$/.exec(current);
35
+ match;
36
+ match = /^(.+)-resume-(\d+)$/.exec(current)
37
+ ) {
38
+ images.push(snapshotImageName(match[1], match[2]));
39
+ current = match[1];
40
+ }
41
+ return images;
42
+ }
43
+
44
+ function attemptOf(image) {
45
+ return Number(/:(\d+)$/.exec(image)?.[1] || 0);
46
+ }
47
+
48
+ function errorText(result) {
49
+ return (
50
+ String(result?.stderr || '').trim() ||
51
+ result?.error ||
52
+ `exit code ${result?.status}`
53
+ );
54
+ }
55
+
56
+ /**
57
+ * The containers and images `--cleanup <execution>` would remove.
58
+ * @param {object} record - Execution record
59
+ * @param {Function} runner - Sync command runner
60
+ * @returns {{containers: string[], images: string[]}}
61
+ */
62
+ function planExecutionCleanup(record, runner) {
63
+ const options = record.options || {};
64
+ const current = options.sessionName || options.containerId || null;
65
+ const containers = [
66
+ ...new Set(
67
+ [current, ...[...(options.sessionNameHistory || [])].reverse()].filter(
68
+ Boolean
69
+ )
70
+ ),
71
+ ];
72
+ const images = [options.snapshotImage];
73
+ for (const container of containers) {
74
+ const labelled = runner(getDockerCommand(), [
75
+ 'inspect',
76
+ '-f',
77
+ `{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`,
78
+ container,
79
+ ]);
80
+ if (labelled.success) {
81
+ images.push(String(labelled.stdout || '').trim());
82
+ }
83
+ images.push(...chainImages(container));
84
+ }
85
+ return {
86
+ containers,
87
+ images: [...new Set(images)]
88
+ .filter((image) => SNAPSHOT_IMAGE_PATTERN.test(image || ''))
89
+ .sort((a, b) => attemptOf(b) - attemptOf(a)),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Remove (or, with `dryRun`, list) one object without force.
95
+ * @returns {{name: string, kind: string, state: string, detail?: string}}
96
+ * `state` is removed, absent, retained or present (dry run)
97
+ */
98
+ function removeObject(kind, name, runner, dryRun) {
99
+ const docker = getDockerCommand();
100
+ const args = dryRun
101
+ ? kind === 'image'
102
+ ? ['image', 'inspect', '--format', '{{.Id}}', name]
103
+ : ['container', 'inspect', '--format', '{{.Id}}', name]
104
+ : kind === 'image'
105
+ ? ['rmi', name]
106
+ : ['rm', name];
107
+ const result = runner(docker, args);
108
+ if (result.success) {
109
+ return { kind, name, state: dryRun ? 'present' : 'removed' };
110
+ }
111
+ const detail = errorText(result);
112
+ if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
113
+ return { kind, name, state: 'absent' };
114
+ }
115
+ return { kind, name, state: 'retained', detail };
116
+ }
117
+
118
+ const LABELS = {
119
+ removed: 'Removed',
120
+ absent: 'Already gone:',
121
+ retained: 'Retained',
122
+ present: 'Would remove',
123
+ };
124
+
125
+ function formatOutcome(outcome) {
126
+ const line = `${LABELS[outcome.state]} ${outcome.kind} ${outcome.name}`;
127
+ return outcome.detail ? `${line} (${outcome.detail})` : line;
128
+ }
129
+
130
+ /**
131
+ * Remove one execution's containers, then its snapshot images.
132
+ * @param {?object} store - Execution store
133
+ * @param {string} identifier - UUID or session name
134
+ * @param {object} [deps] - {dryRun, runner}
135
+ * @returns {{success: boolean, output?: string, error?: string}}
136
+ */
137
+ function cleanupExecution(store, identifier, deps = {}) {
138
+ if (!store) {
139
+ return { success: false, error: 'Execution tracking is disabled.' };
140
+ }
141
+ const record = store.get(identifier);
142
+ if (!record) {
143
+ return {
144
+ success: false,
145
+ error: `No execution found with UUID or session name: ${identifier}`,
146
+ };
147
+ }
148
+ if (record.options?.isolated !== 'docker') {
149
+ return {
150
+ success: false,
151
+ error: `--cleanup <execution> only removes Docker containers and images; ${identifier} is not a Docker execution.`,
152
+ };
153
+ }
154
+ const runner = deps.runner || runCommand;
155
+ const dryRun = deps.dryRun === true;
156
+ const plan = planExecutionCleanup(record, runner);
157
+ const outcomes = plan.containers.map((name) =>
158
+ removeObject('container', name, runner, dryRun)
159
+ );
160
+ // Images go only after their containers; Docker refuses a used image.
161
+ for (const image of plan.images) {
162
+ outcomes.push(removeObject('image', image, runner, dryRun));
163
+ }
164
+ const current = outcomes[0];
165
+ if (
166
+ !dryRun &&
167
+ current &&
168
+ ['removed', 'absent'].includes(current.state) &&
169
+ typeof store.markContainerRemoved === 'function'
170
+ ) {
171
+ store.markContainerRemoved(record.uuid, new Date().toISOString());
172
+ }
173
+ const retained = outcomes.filter((outcome) => outcome.state === 'retained');
174
+ const lines = outcomes.length
175
+ ? outcomes.map(formatOutcome)
176
+ : ['No containers or snapshot images recorded for this execution.'];
177
+ const output = lines.join('\n');
178
+ if (retained.length > 0) {
179
+ return {
180
+ success: false,
181
+ output,
182
+ error: `${retained.length} Docker object(s) retained; nothing was forced.`,
183
+ };
184
+ }
185
+ return { success: true, output };
186
+ }
187
+
188
+ module.exports = {
189
+ SNAPSHOT_IMAGE_PATTERN,
190
+ chainImages,
191
+ cleanupExecution,
192
+ planExecutionCleanup,
193
+ };
@@ -17,10 +17,11 @@ const ControlAction = {
17
17
  TERMINATE: 'terminate',
18
18
  };
19
19
 
20
- function runCommand(command, args) {
20
+ function runCommand(command, args, extra = {}) {
21
21
  const options = {
22
22
  encoding: 'utf8',
23
23
  stdio: ['ignore', 'pipe', 'pipe'],
24
+ ...(extra.timeout ? { timeout: extra.timeout } : {}),
24
25
  };
25
26
  const result = spawnSync(
26
27
  command,
@@ -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
+ };