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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e62079: Executions now carry a stable identity a supervisor can rely on. A launch with an explicit `--session-id` that already exists is refused with exit 1 (`execution <uuid> already exists; use $ --resume <uuid> -- <command> to continue it, or pass --append-attempt`) instead of replacing the record and truncating its log; `--append-attempt` keeps the record, archives the previous attempt in `attemptHistory` and appends the new attempt to the same log (#229). Every backend (direct, `--isolated-user`, screen, tmux, docker, ssh, and docker resumes of stopped containers) exports `START_EXECUTION_ID`, `START_SESSION_NAME` and `START_ATTEMPT` to the command it runs (#230). `$ --alias <id> <name>` adds a name and `$ --rename <id> <name>` sets the display name of an execution, `--continues <id>` records the execution a fresh launch continues, `--status` reports `aliases` and the continuation `chain`, and `--upload-log` uploads the logs of the whole chain, oldest first (#231).
8
+
3
9
  ## 0.37.9
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -29,6 +29,9 @@ $ -i docker -d --label task.tool=codex -- my-task
29
29
  $ --attach <id>
30
30
  $ --resume <id> -- <command>
31
31
  $ --resume-all
32
+ $ --session-id <uuid> --append-attempt -- <command>
33
+ $ --continues <id> -- <command>
34
+ $ --rename <id> <name>
32
35
  ```
33
36
 
34
37
  See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.9",
3
+ "version": "0.38.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -18,7 +18,6 @@ const {
18
18
  getTimestamp,
19
19
  createLogHeader,
20
20
  createLogFooter,
21
- writeLogFile,
22
21
  appendLogFile,
23
22
  createLogPath,
24
23
  getDefaultDockerImage,
@@ -41,6 +40,7 @@ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
41
40
  const { resolveChildExitCode } = require('../lib/exit-reason');
42
41
  const { printUsage } = require('../lib/usage');
43
42
  const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
43
+ const launchIdentity = require('../lib/launch-identity');
44
44
 
45
45
  // A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
46
46
  // start nor replace the command's exit code: drop the failed output and keep
@@ -245,6 +245,8 @@ const useCommandStream =
245
245
 
246
246
  // Generate session ID if not provided (auto-generate UUID)
247
247
  const sessionId = wrapperOptions.sessionId || generateUUID();
248
+ // How this launch uses its execution id (issue #229), set before dispatch
249
+ let launch = null;
248
250
 
249
251
  // Main execution
250
252
  (async () => {
@@ -256,6 +258,19 @@ const sessionId = wrapperOptions.sessionId || generateUUID();
256
258
  process.exit(queryExitCode === null ? 0 : queryExitCode);
257
259
  }
258
260
 
261
+ // An explicit --session-id must not overwrite a tracked execution (#229).
262
+ try {
263
+ launch = launchIdentity.prepareLaunch(
264
+ getExecutionStore(),
265
+ wrapperOptions,
266
+ sessionId,
267
+ createLogPath(wrapperOptions.isolated || 'direct', sessionId)
268
+ );
269
+ } catch (error) {
270
+ console.error(`Error: ${error.message}`);
271
+ process.exit(1);
272
+ }
273
+
259
274
  if (wrapperOptions.isolated === 'docker') {
260
275
  const {
261
276
  resolveResourceOptions,
@@ -314,8 +329,8 @@ async function runWithIsolation(
314
329
  effectiveImage = getDefaultDockerImage();
315
330
  }
316
331
 
317
- // Create log file path
318
- const logFilePath = createLogPath(environment || 'direct', sessionId);
332
+ // Create log file path (an appended attempt keeps the execution's log)
333
+ const logFilePath = launch.logPath;
319
334
 
320
335
  // Get session name (will be generated by runIsolated if not provided)
321
336
  const sessionName =
@@ -424,6 +439,7 @@ async function runWithIsolation(
424
439
  useCommandStream,
425
440
  },
426
441
  });
442
+ launchIdentity.applyLaunch(executionRecord, launch);
427
443
  }
428
444
 
429
445
  // Print start block with session ID and isolation info
@@ -448,8 +464,13 @@ async function runWithIsolation(
448
464
  currentExecutionRecord = executionRecord;
449
465
  require('../lib/launch-owner').markLaunch(executionRecord);
450
466
  try {
451
- store.save(executionRecord);
467
+ launchIdentity.claimLaunch(store, executionRecord, launch);
452
468
  } catch (err) {
469
+ if (err instanceof launchIdentity.LaunchRefusedError) {
470
+ console.error(`Error: ${err.message}`);
471
+ currentExecutionRecord = null;
472
+ process.exit(1);
473
+ }
453
474
  console.error(
454
475
  JSON.stringify({
455
476
  code: 'LAUNCH_RESERVATION_FAILED',
@@ -483,7 +504,12 @@ async function runWithIsolation(
483
504
  );
484
505
  }
485
506
 
486
- writeLogFile(logFilePath, logContent);
507
+ launchIdentity.writeLaunchLog(
508
+ logFilePath,
509
+ logContent,
510
+ launch,
511
+ executionRecord
512
+ );
487
513
 
488
514
  let result;
489
515
 
@@ -495,6 +521,7 @@ async function runWithIsolation(
495
521
  ...options,
496
522
  session: sessionName,
497
523
  sessionId,
524
+ attemptNumber: launch.attemptNumber,
498
525
  image: effectiveImage,
499
526
  endpoint: options.endpoint,
500
527
  detached: mode === 'detached',
@@ -513,7 +540,10 @@ async function runWithIsolation(
513
540
  });
514
541
  } else if (createdUser) {
515
542
  // Run directly as the created user (no isolation environment)
516
- result = await runAsIsolatedUser(cmd, createdUser);
543
+ result = await runAsIsolatedUser(
544
+ launchIdentity.isolatedUserCommand(cmd, sessionId, launch),
545
+ createdUser
546
+ );
517
547
  } else {
518
548
  // This shouldn't happen in isolation mode, but handle gracefully
519
549
  result = { success: false, message: 'No isolation configuration provided' };
@@ -637,8 +667,8 @@ async function runDirect(cmd, sessionId) {
637
667
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
638
668
  const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
639
669
 
640
- // Setup logging
641
- const logFilePath = createLogPath('direct', sessionId);
670
+ // Setup logging (an appended attempt keeps the execution's log)
671
+ const logFilePath = launch.logPath;
642
672
 
643
673
  let logContent = '';
644
674
  const startTime = getTimestamp();
@@ -666,6 +696,8 @@ async function runDirect(cmd, sessionId) {
666
696
  runtimeVersion,
667
697
  },
668
698
  });
699
+ launchIdentity.applyLaunch(executionRecord, launch);
700
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
669
701
  // Set global reference for signal cleanup
670
702
  currentExecutionRecord = executionRecord;
671
703
  }
@@ -746,7 +778,13 @@ async function runDirect(cmd, sessionId) {
746
778
  shell,
747
779
  shellArgs,
748
780
  logFilePath,
749
- logContent,
781
+ logContent: launchIdentity.launchLogContent(
782
+ logContent,
783
+ launch,
784
+ executionRecord
785
+ ),
786
+ appendLog: launch.append,
787
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
750
788
  startTimeMs,
751
789
  executionRecord,
752
790
  store,
@@ -786,8 +824,8 @@ async function runDirectWithCommandStream(
786
824
  const isWindows = process.platform === 'win32';
787
825
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
788
826
 
789
- // Setup logging
790
- const logFilePath = createLogPath('direct', sessionId);
827
+ // Setup logging (an appended attempt keeps the execution's log)
828
+ const logFilePath = launch.logPath;
791
829
 
792
830
  let logContent = '';
793
831
  const startTime = getTimestamp();
@@ -816,6 +854,8 @@ async function runDirectWithCommandStream(
816
854
  executionMode: 'command-stream',
817
855
  },
818
856
  });
857
+ launchIdentity.applyLaunch(executionRecord, launch);
858
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
819
859
  // Set global reference for signal cleanup
820
860
  currentExecutionRecord = executionRecord;
821
861
  }
@@ -853,27 +893,23 @@ async function runDirectWithCommandStream(
853
893
  console.log('[command-stream] Using command-stream library');
854
894
  console.log('');
855
895
 
856
- // Save initial execution record (PID will be updated later if available)
857
- if (executionRecord && store) {
858
- try {
859
- store.save(executionRecord);
860
- } catch (err) {
861
- if (config.verbose) {
862
- console.error(
863
- `[Tracking] Warning: Could not save execution record: ${err.message}`
864
- );
865
- }
866
- }
867
- }
868
-
869
896
  // Execute the command using command-stream with real-time output
870
897
  // Using mirror: true to show output in real-time, capture: true to collect it
871
898
  // Using raw() to avoid auto-escaping that might interfere with complex shell commands
872
- const $cmd = $({ mirror: true, capture: true });
899
+ const $cmd = $({
900
+ mirror: true,
901
+ capture: true,
902
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
903
+ });
873
904
 
874
905
  let exitCode;
875
906
  try {
876
- writeLogFile(logFilePath, logContent);
907
+ launchIdentity.writeLaunchLog(
908
+ logFilePath,
909
+ logContent,
910
+ launch,
911
+ executionRecord
912
+ );
877
913
  // Use raw() to pass the command without auto-escaping
878
914
  // This is important for complex commands with pipes, redirects, etc.
879
915
  const result = await $cmd`${raw(cmd)}`;
@@ -9,6 +9,7 @@
9
9
  * Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
10
10
  * --resume [-- <command>], --resume-all,
11
11
  * --cleanup [<uuid-or-session-name>]
12
+ * Naming modes: --alias <id> <name>, --rename <id> <name> (issue #231)
12
13
  */
13
14
 
14
15
  /**
@@ -39,6 +40,9 @@ function createQueryOptionDefaults() {
39
40
  cleanup: false, // Clean up stale "executing" records
40
41
  cleanupDryRun: false, // Show what would be cleaned without cleaning
41
42
  cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
43
+ alias: null, // Execution that --alias adds a name to
44
+ rename: null, // Execution that --rename renames
45
+ newName: null, // Name given by --alias/--rename
42
46
  };
43
47
  }
44
48
 
@@ -120,6 +124,35 @@ function hasValue(value) {
120
124
  return value !== null && value !== undefined;
121
125
  }
122
126
 
127
+ /**
128
+ * `--alias <id> <name>` and `--rename <id> <name>` take two arguments.
129
+ */
130
+ const NAMING_OPTIONS = {
131
+ '--alias': 'alias',
132
+ '--rename': 'rename',
133
+ };
134
+
135
+ function parseNamingOption(args, index, options) {
136
+ const key = NAMING_OPTIONS[args[index]];
137
+ if (!key) {
138
+ return 0;
139
+ }
140
+ const [identifier, name] = args.slice(index + 1, index + 3);
141
+ if (
142
+ identifier === undefined ||
143
+ name === undefined ||
144
+ identifier.startsWith('-') ||
145
+ name.startsWith('-')
146
+ ) {
147
+ throw new Error(
148
+ `Option ${args[index]} requires a UUID or session name and a new name`
149
+ );
150
+ }
151
+ options[key] = identifier;
152
+ options.newName = name;
153
+ return 3;
154
+ }
155
+
123
156
  /**
124
157
  * Parse a single query/control option.
125
158
  * @param {string[]} args - Arguments array
@@ -135,6 +168,11 @@ function parseQueryOption(args, index, options) {
135
168
  return cleanupConsumed;
136
169
  }
137
170
 
171
+ const namingConsumed = parseNamingOption(args, index, options);
172
+ if (namingConsumed > 0) {
173
+ return namingConsumed;
174
+ }
175
+
138
176
  const flag = FLAG_OPTIONS[arg];
139
177
  if (flag) {
140
178
  flag(options);
@@ -216,6 +254,12 @@ function activeQueryModes(options) {
216
254
  if (options.cleanup) {
217
255
  modes.push('--cleanup');
218
256
  }
257
+ if (hasValue(options.alias)) {
258
+ modes.push('--alias');
259
+ }
260
+ if (hasValue(options.rename)) {
261
+ modes.push('--rename');
262
+ }
219
263
  return modes;
220
264
  }
221
265
 
@@ -248,7 +292,7 @@ function validateQueryOptions(options) {
248
292
 
249
293
  if (modes.length > 1) {
250
294
  throw new Error(
251
- 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup in the same invocation'
295
+ 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename in the same invocation'
252
296
  );
253
297
  }
254
298
 
@@ -39,6 +39,7 @@
39
39
  * --resume-all Re-attach or reconcile every execution still marked running
40
40
  * --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
41
41
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
42
+ * --alias <id> <name>, --rename <id> <name>, --append-attempt, --continues <id>: see args-parser-queries.js, launch-identity-options.js
42
43
  */
43
44
 
44
45
  const { getDefaultDockerImage } = require('./docker-utils');
@@ -47,6 +48,7 @@ const dockerRecoveryOptions = require('./docker-recovery-options');
47
48
  const cpuPenalty = require('./cpu-penalty');
48
49
  const dockerResourceOptions = require('./docker-resource-options');
49
50
  const snapshotResumeOptions = require('./snapshot-resume-options');
51
+ const launchIdentityOptions = require('./launch-identity-options');
50
52
  const { parseSequence, isSequence } = require('./sequence-parser');
51
53
  const { buildCommandString } = require('./shell-utils');
52
54
  const {
@@ -217,6 +219,7 @@ function parseArgs(args) {
217
219
  useCommandStream: false, // Use command-stream library for command execution
218
220
  ...createQueryOptionDefaults(),
219
221
  ...snapshotResumeOptions.createSnapshotOptionDefaults(),
222
+ ...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
220
223
  };
221
224
 
222
225
  let commandArgs = [];
@@ -580,6 +583,12 @@ function parseOption(args, index, options) {
580
583
  return queryConsumed;
581
584
  }
582
585
 
586
+ // --append-attempt, --continues <id> (issues #229, #231)
587
+ const identityConsumed = launchIdentityOptions.parse(args, index, options);
588
+ if (identityConsumed > 0) {
589
+ return identityConsumed;
590
+ }
591
+
583
592
  // Not a recognized wrapper option
584
593
  return 0;
585
594
  }
@@ -830,6 +839,7 @@ function validateOptions(options) {
830
839
 
831
840
  snapshotResumeOptions.validate(options);
832
841
  validateQueryOptions(options);
842
+ launchIdentityOptions.validate(options);
833
843
 
834
844
  // Validate shell option
835
845
  if (options.shell !== null && options.shell !== undefined) {
@@ -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
  };
@@ -42,6 +42,13 @@ function buildDockerRuntimeArgs(options = {}) {
42
42
  for (const envVar of options.env || []) {
43
43
  args.push('-e', envVar);
44
44
  }
45
+ // Execution identity (issue #230); last, so a user `--env` cannot spoof it.
46
+ const executionEnv = require('./execution-env');
47
+ for (const envVar of executionEnv.executionEnvAssignments(
48
+ executionEnv.executionEnvFromOptions(options)
49
+ )) {
50
+ args.push('-e', envVar);
51
+ }
45
52
  for (const label of require('./docker-labels').dockerLabels(options)) {
46
53
  args.push('--label', label);
47
54
  }
@@ -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,
@@ -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
+ };