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 +6 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/bin/cli.js +62 -26
- package/src/lib/args-parser-queries.js +45 -1
- package/src/lib/args-parser.js +10 -0
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-runtime-args.js +7 -0
- package/src/lib/execution-attempt.js +19 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +17 -0
- package/src/lib/execution-resume.js +17 -0
- package/src/lib/execution-store.js +11 -22
- package/src/lib/isolation.js +21 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +18 -3
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +5 -1
- package/src/lib/usage.js +8 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +15 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a new launch may do with its execution id (issue #229).
|
|
3
|
+
*
|
|
4
|
+
* A launch with an explicit `--session-id` that is already tracked used to
|
|
5
|
+
* replace the record and truncate the log. It is now refused unless
|
|
6
|
+
* `--append-attempt` is given, which keeps the record, archives the previous
|
|
7
|
+
* attempt into `attemptHistory`, and appends the new attempt to the same log
|
|
8
|
+
* after a lifecycle header (the layout `--resume` already writes).
|
|
9
|
+
*
|
|
10
|
+
* Two cases keep the earlier behaviour:
|
|
11
|
+
* - the next level of a stacked isolation, which receives the same
|
|
12
|
+
* `--session-id` from its parent and runs with START_EXECUTION_ID already
|
|
13
|
+
* set to it (`isNestedLaunchOf`);
|
|
14
|
+
* - a generated UUID, which cannot collide.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const { isNestedLaunchOf, ATTEMPT_ENV } = require('./execution-env');
|
|
18
|
+
const { findRecord, withStoreLock } = require('./execution-identity');
|
|
19
|
+
|
|
20
|
+
const LOG_HEADER = '=== Start Command Log ===';
|
|
21
|
+
|
|
22
|
+
class LaunchRefusedError extends Error {}
|
|
23
|
+
|
|
24
|
+
function alreadyExistsMessage(uuid) {
|
|
25
|
+
return `execution ${uuid} already exists; use $ --resume ${uuid} -- <command> to continue it, or pass --append-attempt`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPidAlive(pid) {
|
|
29
|
+
if (!pid) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return error.code === 'EPERM';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True while the stored execution may still be running. */
|
|
41
|
+
function isExecutionLive(record) {
|
|
42
|
+
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (record.status !== 'executing') {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (record.options && record.options.isolated) {
|
|
49
|
+
try {
|
|
50
|
+
return require('./session-probe').probeSession(record).alive === true;
|
|
51
|
+
} catch {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return isPidAlive(record.pid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Attempts already in a log written without tracking, from its headers. */
|
|
59
|
+
function countLoggedAttempts(logPath) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(logPath, 'utf8').split(LOG_HEADER).length - 1;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function logSize(logPath) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.statSync(logPath).size;
|
|
70
|
+
} catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decide how a launch uses its execution id, before anything is printed or
|
|
77
|
+
* created.
|
|
78
|
+
* @param {object} params - {store, sessionId, explicitSessionId,
|
|
79
|
+
* appendAttempt, logPath, env}
|
|
80
|
+
* @returns {{append: boolean, previous: object|null, attemptNumber: number,
|
|
81
|
+
* logPath: string, nested: boolean, checked: boolean}}
|
|
82
|
+
* @throws {LaunchRefusedError} When the launch must not run
|
|
83
|
+
*/
|
|
84
|
+
function preflightLaunch(params) {
|
|
85
|
+
const { store, sessionId, logPath } = params;
|
|
86
|
+
const env = params.env || process.env;
|
|
87
|
+
const fresh = {
|
|
88
|
+
append: false,
|
|
89
|
+
previous: null,
|
|
90
|
+
attemptNumber: 1,
|
|
91
|
+
logPath,
|
|
92
|
+
nested: false,
|
|
93
|
+
checked: false,
|
|
94
|
+
};
|
|
95
|
+
if (!params.explicitSessionId) {
|
|
96
|
+
return fresh;
|
|
97
|
+
}
|
|
98
|
+
if (isNestedLaunchOf(sessionId, env)) {
|
|
99
|
+
const inherited = Number(env[ATTEMPT_ENV]);
|
|
100
|
+
return {
|
|
101
|
+
...fresh,
|
|
102
|
+
nested: true,
|
|
103
|
+
attemptNumber:
|
|
104
|
+
Number.isSafeInteger(inherited) && inherited > 0 ? inherited : 1,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const existing = store ? store.get(sessionId) : null;
|
|
108
|
+
if (existing && existing.uuid !== sessionId) {
|
|
109
|
+
throw new LaunchRefusedError(
|
|
110
|
+
`execution id ${sessionId} is already used as a name of execution ${existing.uuid}; pick another --session-id`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (!params.appendAttempt) {
|
|
115
|
+
throw new LaunchRefusedError(alreadyExistsMessage(sessionId));
|
|
116
|
+
}
|
|
117
|
+
if (isExecutionLive(existing)) {
|
|
118
|
+
throw new LaunchRefusedError(
|
|
119
|
+
`execution ${sessionId} is still running; wait for it to finish or use $ --stop ${sessionId} before --append-attempt`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const { nextAttemptNumber } = require('./execution-attempt');
|
|
123
|
+
return {
|
|
124
|
+
...fresh,
|
|
125
|
+
append: true,
|
|
126
|
+
previous: existing,
|
|
127
|
+
attemptNumber: nextAttemptNumber(existing),
|
|
128
|
+
logPath: existing.logPath || logPath,
|
|
129
|
+
checked: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Untracked (or deleted) executions are recognised by their log file.
|
|
133
|
+
if (logPath && fs.existsSync(logPath)) {
|
|
134
|
+
if (!params.appendAttempt) {
|
|
135
|
+
throw new LaunchRefusedError(alreadyExistsMessage(sessionId));
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...fresh,
|
|
139
|
+
append: true,
|
|
140
|
+
attemptNumber: countLoggedAttempts(logPath) + 1,
|
|
141
|
+
checked: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ...fresh, checked: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Turn the record a launch built into the next attempt of `previous`,
|
|
149
|
+
* keeping the identity, history and log of the execution.
|
|
150
|
+
* @param {object} record - Freshly built ExecutionRecord (mutated)
|
|
151
|
+
* @param {object} previous - Stored record of the same UUID
|
|
152
|
+
* @returns {object} The record
|
|
153
|
+
*/
|
|
154
|
+
function mergeAppendedAttempt(record, previous) {
|
|
155
|
+
const {
|
|
156
|
+
archiveAttempt,
|
|
157
|
+
createAttempt,
|
|
158
|
+
currentAttemptNumber,
|
|
159
|
+
} = require('./execution-attempt');
|
|
160
|
+
const prior = require('./execution-store').ExecutionRecord.fromObject(
|
|
161
|
+
JSON.parse(JSON.stringify(previous.toObject()))
|
|
162
|
+
);
|
|
163
|
+
if (!prior.attempt && currentAttemptNumber(prior) === 1) {
|
|
164
|
+
// A launch writes a fresh log, so its first attempt starts at offset 0.
|
|
165
|
+
prior.attempt = { number: 1, startedAt: prior.startTime, logOffset: 0 };
|
|
166
|
+
}
|
|
167
|
+
const attempt = createAttempt(prior, {
|
|
168
|
+
mode: 'append-attempt',
|
|
169
|
+
sessionName: record.options.sessionName || null,
|
|
170
|
+
});
|
|
171
|
+
archiveAttempt(prior);
|
|
172
|
+
const keep = prior.options || {};
|
|
173
|
+
const history = [...(keep.sessionNameHistory || [])];
|
|
174
|
+
if (keep.sessionName && keep.sessionName !== record.options.sessionName) {
|
|
175
|
+
history.push(keep.sessionName);
|
|
176
|
+
}
|
|
177
|
+
record.attempt = attempt;
|
|
178
|
+
record.attemptHistory = prior.attemptHistory;
|
|
179
|
+
record.startTime = prior.startTime;
|
|
180
|
+
record.logPath = prior.logPath || record.logPath;
|
|
181
|
+
record.continues = record.continues || prior.continues;
|
|
182
|
+
record.options = {
|
|
183
|
+
...record.options,
|
|
184
|
+
...(history.length ? { sessionNameHistory: [...new Set(history)] } : {}),
|
|
185
|
+
...(keep.aliases ? { aliases: keep.aliases } : {}),
|
|
186
|
+
...(keep.name ? { name: keep.name } : {}),
|
|
187
|
+
...(keep.resumeCount ? { resumeCount: keep.resumeCount } : {}),
|
|
188
|
+
};
|
|
189
|
+
return record;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Persist the launch's record, refusing if another launch claimed the id in
|
|
194
|
+
* the meantime: a new id must still be free, an appended attempt must still
|
|
195
|
+
* follow the attempt the preflight saw.
|
|
196
|
+
* @param {object} store - ExecutionStore
|
|
197
|
+
* @param {object} record - Record to save
|
|
198
|
+
* @param {object} launch - preflightLaunch() result
|
|
199
|
+
*/
|
|
200
|
+
function claimLaunch(store, record, launch) {
|
|
201
|
+
if (!launch.checked) {
|
|
202
|
+
store.save(record);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
withStoreLock(store, (records) => {
|
|
206
|
+
const index = records.findIndex((r) => r.uuid === record.uuid);
|
|
207
|
+
if (launch.previous) {
|
|
208
|
+
const current = records[index];
|
|
209
|
+
if (
|
|
210
|
+
!current ||
|
|
211
|
+
require('./launch-owner').hasActiveLaunch(current) ||
|
|
212
|
+
(current.attempt?.number || 0) !==
|
|
213
|
+
(launch.previous.attempt?.number || 0)
|
|
214
|
+
) {
|
|
215
|
+
throw new LaunchRefusedError(
|
|
216
|
+
`execution ${record.uuid} was claimed by another launch`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
records[index] = record;
|
|
220
|
+
} else {
|
|
221
|
+
if (findRecord(records, record.uuid)) {
|
|
222
|
+
throw new LaunchRefusedError(alreadyExistsMessage(record.uuid));
|
|
223
|
+
}
|
|
224
|
+
records.push(record);
|
|
225
|
+
}
|
|
226
|
+
return record;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Header a launch writes into its log: unchanged for a fresh log; for an
|
|
232
|
+
* appended attempt, preceded by a lifecycle line in the format `--resume`
|
|
233
|
+
* writes, so the attempts stay apart.
|
|
234
|
+
* @param {string} content - Header content
|
|
235
|
+
* @param {object} launch - preflightLaunch() result
|
|
236
|
+
* @param {object|null} record - Record of the launch, when tracked
|
|
237
|
+
* @returns {string} Content to write
|
|
238
|
+
*/
|
|
239
|
+
function launchLogContent(content, launch, record = null) {
|
|
240
|
+
if (!launch.append) {
|
|
241
|
+
return content;
|
|
242
|
+
}
|
|
243
|
+
const lifecycle = {
|
|
244
|
+
event: 'attempt-appended',
|
|
245
|
+
at: new Date().toISOString(),
|
|
246
|
+
uuid: record ? record.uuid : null,
|
|
247
|
+
attemptNumber: launch.attemptNumber,
|
|
248
|
+
...(record && record.attempt ? record.attempt : {}),
|
|
249
|
+
};
|
|
250
|
+
return `\n[Start Command Lifecycle] ${JSON.stringify(lifecycle)}\n${content}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Save the initial record of a direct run, before it starts. A refused
|
|
255
|
+
* claim ends the launch with exit code 1; other failures only lose tracking.
|
|
256
|
+
* @param {object|null} store - ExecutionStore
|
|
257
|
+
* @param {object|null} record - Execution record
|
|
258
|
+
* @param {object} launch - prepareLaunch() result
|
|
259
|
+
* @param {object} [config] - CLI configuration ({verbose})
|
|
260
|
+
*/
|
|
261
|
+
function claimDirectLaunch(store, record, launch, config = {}) {
|
|
262
|
+
if (!record || !store) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
claimLaunch(store, record, launch);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err instanceof LaunchRefusedError) {
|
|
269
|
+
console.error(`Error: ${err.message}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
if (config.verbose) {
|
|
273
|
+
console.error(
|
|
274
|
+
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Write the launch header: a fresh log, or, for an appended attempt, a
|
|
282
|
+
* lifecycle line and the header after the earlier attempts.
|
|
283
|
+
* @param {string} logPath - Log path
|
|
284
|
+
* @param {string} content - Header content
|
|
285
|
+
* @param {object} launch - preflightLaunch() result
|
|
286
|
+
* @param {object|null} record - Record of the launch, when tracked
|
|
287
|
+
*/
|
|
288
|
+
function writeLaunchLog(logPath, content, launch, record = null) {
|
|
289
|
+
const { appendLogFile, writeLogFile } = require('./isolation-log-utils');
|
|
290
|
+
const text = launchLogContent(content, launch, record);
|
|
291
|
+
if (launch.append) {
|
|
292
|
+
appendLogFile(logPath, text);
|
|
293
|
+
} else {
|
|
294
|
+
writeLogFile(logPath, text);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Preflight of the CLI: how this launch uses its id (#229) and which
|
|
300
|
+
* execution it continues (#231).
|
|
301
|
+
* @param {object} store - ExecutionStore, or null when tracking is off
|
|
302
|
+
* @param {object} options - Parsed wrapper options
|
|
303
|
+
* @param {string} sessionId - Execution UUID of the launch
|
|
304
|
+
* @param {string} logPath - Default log path of the launch
|
|
305
|
+
* @returns {object} preflightLaunch() result plus `continues`
|
|
306
|
+
* @throws {Error} When the launch must not run
|
|
307
|
+
*/
|
|
308
|
+
function prepareLaunch(store, options, sessionId, logPath) {
|
|
309
|
+
const launch = preflightLaunch({
|
|
310
|
+
store,
|
|
311
|
+
sessionId,
|
|
312
|
+
explicitSessionId: Boolean(options.sessionId),
|
|
313
|
+
appendAttempt: Boolean(options.appendAttempt),
|
|
314
|
+
logPath,
|
|
315
|
+
});
|
|
316
|
+
launch.continues = options.continues
|
|
317
|
+
? require('./execution-chain').resolveContinues(
|
|
318
|
+
store,
|
|
319
|
+
options.continues,
|
|
320
|
+
sessionId
|
|
321
|
+
)
|
|
322
|
+
: null;
|
|
323
|
+
return launch;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Apply the preflight to the record a launch built: the continued UUID and,
|
|
328
|
+
* for `--append-attempt`, the identity and history of the stored execution.
|
|
329
|
+
* @param {object|null} record - Freshly built ExecutionRecord (mutated)
|
|
330
|
+
* @param {object} launch - prepareLaunch() result
|
|
331
|
+
* @returns {object|null} The record
|
|
332
|
+
*/
|
|
333
|
+
function applyLaunch(record, launch) {
|
|
334
|
+
if (!record) {
|
|
335
|
+
return record;
|
|
336
|
+
}
|
|
337
|
+
if (launch.continues) {
|
|
338
|
+
record.continues = launch.continues;
|
|
339
|
+
}
|
|
340
|
+
if (launch.previous) {
|
|
341
|
+
mergeAppendedAttempt(record, launch.previous);
|
|
342
|
+
} else if (launch.attemptNumber > 1) {
|
|
343
|
+
// A nested level or an untracked log: number the attempt all the same.
|
|
344
|
+
record.attempt = {
|
|
345
|
+
number: launch.attemptNumber,
|
|
346
|
+
startedAt: new Date().toISOString(),
|
|
347
|
+
logOffset: launch.append ? logSize(launch.logPath) : 0,
|
|
348
|
+
mode: launch.nested ? 'nested' : 'append-attempt',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return record;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* START_* variables of a run without a backend session (direct, or
|
|
356
|
+
* `--isolated-user` alone): the session name is empty.
|
|
357
|
+
* @param {string} sessionId - Execution UUID
|
|
358
|
+
* @param {object} launch - prepareLaunch() result
|
|
359
|
+
* @returns {object} Variables
|
|
360
|
+
*/
|
|
361
|
+
function directExecutionEnv(sessionId, launch) {
|
|
362
|
+
return require('./execution-env').buildExecutionEnv({
|
|
363
|
+
executionId: sessionId,
|
|
364
|
+
sessionName: '',
|
|
365
|
+
attempt: launch.attemptNumber,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Child environment of a direct run.
|
|
371
|
+
* @param {string} sessionId - Execution UUID
|
|
372
|
+
* @param {object} launch - prepareLaunch() result
|
|
373
|
+
* @returns {object} Child environment
|
|
374
|
+
*/
|
|
375
|
+
function directLaunchEnv(sessionId, launch) {
|
|
376
|
+
return require('./execution-env').withExecutionEnv(
|
|
377
|
+
directExecutionEnv(sessionId, launch)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Command of an `--isolated-user` run without a backend: `sudo` resets the
|
|
383
|
+
* environment, so the variables are exported by the command itself.
|
|
384
|
+
* @param {string} cmd - Shell command
|
|
385
|
+
* @param {string} sessionId - Execution UUID
|
|
386
|
+
* @param {object} launch - prepareLaunch() result
|
|
387
|
+
* @returns {string} Prefixed command
|
|
388
|
+
*/
|
|
389
|
+
function isolatedUserCommand(cmd, sessionId, launch) {
|
|
390
|
+
return require('./execution-env').prefixWithExecutionEnv(
|
|
391
|
+
cmd,
|
|
392
|
+
directExecutionEnv(sessionId, launch)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
LaunchRefusedError,
|
|
398
|
+
alreadyExistsMessage,
|
|
399
|
+
applyLaunch,
|
|
400
|
+
claimDirectLaunch,
|
|
401
|
+
claimLaunch,
|
|
402
|
+
directExecutionEnv,
|
|
403
|
+
directLaunchEnv,
|
|
404
|
+
isolatedUserCommand,
|
|
405
|
+
isExecutionLive,
|
|
406
|
+
launchLogContent,
|
|
407
|
+
mergeAppendedAttempt,
|
|
408
|
+
prepareLaunch,
|
|
409
|
+
preflightLaunch,
|
|
410
|
+
writeLaunchLog,
|
|
411
|
+
};
|
package/src/lib/log-uploader.js
CHANGED
|
@@ -200,7 +200,21 @@ function uploadExecutionLog(store, identifier, options = {}) {
|
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
// A continuation chain uploads every execution, oldest first (#231).
|
|
204
|
+
let prepared;
|
|
205
|
+
try {
|
|
206
|
+
prepared = require('./execution-chain').prepareUploadLog(store, record);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
error: `Could not join chain logs: ${error.message}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
return uploadLogPath(prepared.path, options);
|
|
215
|
+
} finally {
|
|
216
|
+
prepared.cleanup();
|
|
217
|
+
}
|
|
204
218
|
}
|
|
205
219
|
|
|
206
220
|
module.exports = {
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Every option in this module addresses an execution that already exists in
|
|
5
5
|
* the tracking store instead of launching a new one: --status, --list,
|
|
6
6
|
* --upload-log, --stop, --terminate, --attach, --resume, --resume-all and
|
|
7
|
-
* --cleanup. `dispatchQueryCommand` is the single
|
|
8
|
-
* before it starts interpreting the command line as
|
|
7
|
+
* --cleanup, plus --alias and --rename. `dispatchQueryCommand` is the single
|
|
8
|
+
* entry point the CLI calls before it starts interpreting the command line as
|
|
9
|
+
* a command to run.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const { queryStatus, listExecutions } = require('./status-formatter');
|
|
@@ -157,7 +158,9 @@ function hasQueryMode(options) {
|
|
|
157
158
|
hasValue(options.attach) ||
|
|
158
159
|
hasValue(options.resume) ||
|
|
159
160
|
options.resumeAll === true ||
|
|
160
|
-
options.cleanup === true
|
|
161
|
+
options.cleanup === true ||
|
|
162
|
+
hasValue(options.alias) ||
|
|
163
|
+
hasValue(options.rename)
|
|
161
164
|
);
|
|
162
165
|
}
|
|
163
166
|
|
|
@@ -208,6 +211,18 @@ async function dispatchQueryCommand(options, context = {}) {
|
|
|
208
211
|
if (options.cleanup) {
|
|
209
212
|
return handleCleanup(store, options.cleanupDryRun, options.cleanupTarget);
|
|
210
213
|
}
|
|
214
|
+
if (hasValue(options.alias) || hasValue(options.rename)) {
|
|
215
|
+
const { nameExecution } = require('./execution-identity');
|
|
216
|
+
const rename = hasValue(options.rename);
|
|
217
|
+
return reportResult(
|
|
218
|
+
nameExecution(
|
|
219
|
+
store,
|
|
220
|
+
rename ? options.rename : options.alias,
|
|
221
|
+
options.newName,
|
|
222
|
+
{ rename }
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
211
226
|
|
|
212
227
|
return null;
|
|
213
228
|
}
|
|
@@ -128,6 +128,8 @@ function supportsLogfileOption() {
|
|
|
128
128
|
* @param {string|null} user - Username to run command as (optional)
|
|
129
129
|
* @param {Function} wrapCommandWithUser - Function to wrap command with user
|
|
130
130
|
* @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
|
|
131
|
+
* @param {string|null} logPath - Log file to append to (optional)
|
|
132
|
+
* @param {object|null} env - Extra environment, e.g. START_* (optional)
|
|
131
133
|
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
|
|
132
134
|
*/
|
|
133
135
|
function runScreenWithLogCapture(
|
|
@@ -137,7 +139,8 @@ function runScreenWithLogCapture(
|
|
|
137
139
|
user = null,
|
|
138
140
|
wrapCommandWithUser,
|
|
139
141
|
isInteractiveShellCommand,
|
|
140
|
-
logPath = null
|
|
142
|
+
logPath = null,
|
|
143
|
+
env = null
|
|
141
144
|
) {
|
|
142
145
|
const { shell, shellArg } = shellInfo;
|
|
143
146
|
const screenTempDir = getTempDir('isolation', 'screen');
|
|
@@ -237,8 +240,10 @@ function runScreenWithLogCapture(
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
243
|
+
// Each `screen -dmS` server inherits this env (START_*, issue #230).
|
|
240
244
|
const result = spawnSync('screen', screenArgs, {
|
|
241
245
|
stdio: 'inherit',
|
|
246
|
+
env: { ...process.env, ...env },
|
|
242
247
|
});
|
|
243
248
|
|
|
244
249
|
if (result.error) {
|
|
@@ -424,6 +429,7 @@ function startDetachedScreenWithLogCapture(
|
|
|
424
429
|
keepAlive = false,
|
|
425
430
|
logPath = null,
|
|
426
431
|
wrapCommandWithUser,
|
|
432
|
+
env = null,
|
|
427
433
|
} = options;
|
|
428
434
|
|
|
429
435
|
const screenTempDir = getTempDir('isolation', 'screen');
|
|
@@ -471,7 +477,10 @@ function startDetachedScreenWithLogCapture(
|
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
try {
|
|
474
|
-
const result = spawnSync('screen', screenArgs, {
|
|
480
|
+
const result = spawnSync('screen', screenArgs, {
|
|
481
|
+
stdio: 'inherit',
|
|
482
|
+
env: { ...process.env, ...env },
|
|
483
|
+
});
|
|
475
484
|
if (result.error) {
|
|
476
485
|
throw result.error;
|
|
477
486
|
}
|
package/src/lib/spawn-helpers.js
CHANGED
|
@@ -18,9 +18,14 @@ function ensureParentDirectory(filePath) {
|
|
|
18
18
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function writeInitialLog(logFilePath, logContent) {
|
|
21
|
+
function writeInitialLog(logFilePath, logContent, append = false) {
|
|
22
22
|
ensureParentDirectory(logFilePath);
|
|
23
|
-
|
|
23
|
+
if (append) {
|
|
24
|
+
// `--append-attempt` (issue #229): never truncate earlier attempts.
|
|
25
|
+
fs.appendFileSync(logFilePath, logContent || '', 'utf8');
|
|
26
|
+
} else {
|
|
27
|
+
fs.writeFileSync(logFilePath, logContent || '', 'utf8');
|
|
28
|
+
}
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
function appendLog(logFilePath, content) {
|
|
@@ -40,6 +45,8 @@ function appendLog(logFilePath, content) {
|
|
|
40
45
|
* @param {Object} options.executionRecord - Execution tracking record
|
|
41
46
|
* @param {Object} options.store - Execution store
|
|
42
47
|
* @param {Object} options.config - CLI configuration
|
|
48
|
+
* @param {Object} [options.env] - Child environment (defaults to process.env)
|
|
49
|
+
* @param {boolean} [options.appendLog] - Append to an existing log (issue #229)
|
|
43
50
|
* @param {Function} options.onComplete - Callback for completion (exitCode, endTime, logContent, durationMs)
|
|
44
51
|
* @param {Function} options.onError - Callback for errors (errorMessage, endTime, durationMs)
|
|
45
52
|
*/
|
|
@@ -54,18 +61,21 @@ async function runWithBunSpawn(options) {
|
|
|
54
61
|
config,
|
|
55
62
|
onComplete,
|
|
56
63
|
onError,
|
|
64
|
+
env,
|
|
65
|
+
appendLog: appendToLog = false,
|
|
57
66
|
} = options;
|
|
58
67
|
|
|
59
68
|
let logContent = options.logContent || '';
|
|
60
69
|
|
|
61
70
|
try {
|
|
62
|
-
writeInitialLog(logFilePath, logContent);
|
|
71
|
+
writeInitialLog(logFilePath, logContent, appendToLog);
|
|
63
72
|
|
|
64
73
|
// Spawn the process using Bun's native API
|
|
65
74
|
const proc = Bun.spawn([shell, ...shellArgs], {
|
|
66
75
|
stdout: 'pipe',
|
|
67
76
|
stderr: 'pipe',
|
|
68
77
|
stdin: 'inherit',
|
|
78
|
+
...(env ? { env } : {}),
|
|
69
79
|
});
|
|
70
80
|
|
|
71
81
|
// Update execution record with PID and save initial state
|
|
@@ -186,14 +196,16 @@ async function runWithBunSpawn(options) {
|
|
|
186
196
|
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
187
197
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
188
198
|
|
|
189
|
-
|
|
190
|
-
logContent +=
|
|
191
|
-
logContent += `Finished: ${endTime}\n`;
|
|
192
|
-
logContent += `Exit Code: 1\n`;
|
|
199
|
+
const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
|
|
200
|
+
logContent += errorTail;
|
|
193
201
|
|
|
194
|
-
// Write log file
|
|
202
|
+
// Write log file; an appended attempt keeps the earlier attempts (#229).
|
|
195
203
|
try {
|
|
196
|
-
|
|
204
|
+
if (appendToLog) {
|
|
205
|
+
writeInitialLog(logFilePath, errorTail, true);
|
|
206
|
+
} else {
|
|
207
|
+
writeInitialLog(logFilePath, logContent);
|
|
208
|
+
}
|
|
197
209
|
} catch (writeErr) {
|
|
198
210
|
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
199
211
|
}
|
|
@@ -238,15 +250,18 @@ function runWithNodeSpawn(options) {
|
|
|
238
250
|
config,
|
|
239
251
|
onComplete,
|
|
240
252
|
onError,
|
|
253
|
+
env,
|
|
254
|
+
appendLog: appendToLog = false,
|
|
241
255
|
} = options;
|
|
242
256
|
|
|
243
257
|
let logContent = options.logContent || '';
|
|
244
|
-
writeInitialLog(logFilePath, logContent);
|
|
258
|
+
writeInitialLog(logFilePath, logContent, appendToLog);
|
|
245
259
|
|
|
246
260
|
// Execute the command with captured output
|
|
247
261
|
const child = spawn(shell, shellArgs, {
|
|
248
262
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
249
263
|
shell: false,
|
|
264
|
+
...(env ? { env } : {}),
|
|
250
265
|
});
|
|
251
266
|
|
|
252
267
|
// Update execution record with PID and save initial state
|
|
@@ -330,14 +345,16 @@ function runWithNodeSpawn(options) {
|
|
|
330
345
|
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
331
346
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
332
347
|
|
|
333
|
-
|
|
334
|
-
logContent +=
|
|
335
|
-
logContent += `Finished: ${endTime}\n`;
|
|
336
|
-
logContent += `Exit Code: 1\n`;
|
|
348
|
+
const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
|
|
349
|
+
logContent += errorTail;
|
|
337
350
|
|
|
338
|
-
// Write log file
|
|
351
|
+
// Write log file; an appended attempt keeps the earlier attempts (#229).
|
|
339
352
|
try {
|
|
340
|
-
|
|
353
|
+
if (appendToLog) {
|
|
354
|
+
writeInitialLog(logFilePath, errorTail, true);
|
|
355
|
+
} else {
|
|
356
|
+
writeInitialLog(logFilePath, logContent);
|
|
357
|
+
}
|
|
341
358
|
} catch (writeErr) {
|
|
342
359
|
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
343
360
|
}
|
|
@@ -729,6 +729,7 @@ function formatRecordAsText(record) {
|
|
|
729
729
|
`UUID: ${obj.uuid}`,
|
|
730
730
|
`Status: ${obj.status}`,
|
|
731
731
|
`Command: ${obj.command}`,
|
|
732
|
+
...require('./execution-chain').identityTextLines(obj),
|
|
732
733
|
`Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
|
|
733
734
|
...(obj.oomKilled !== undefined
|
|
734
735
|
? [`OOM Killed: ${obj.oomKilled}`]
|
|
@@ -924,7 +925,10 @@ function queryStatus(store, identifier, outputFormat) {
|
|
|
924
925
|
persistContainerRemoval(store, record, enrichedRecord);
|
|
925
926
|
const withProcessIds = attachProcessIds(enrichedRecord);
|
|
926
927
|
// Attach currentTime so callers can see how long an executing command has been running
|
|
927
|
-
const withCurrentTime =
|
|
928
|
+
const withCurrentTime = require('./execution-chain').attachIdentity(
|
|
929
|
+
attachCurrentTime(withProcessIds),
|
|
930
|
+
store
|
|
931
|
+
);
|
|
928
932
|
return {
|
|
929
933
|
success: true,
|
|
930
934
|
output: formatRecord(withCurrentTime, outputFormat || 'links-notation'),
|
package/src/lib/usage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Print usage information */
|
|
2
2
|
function printUsage() {
|
|
3
|
-
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all
|
|
3
|
+
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all | $ --alias <id> <name> | $ --rename <id> <name>
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
6
|
--help, -h Show this usage and exit successfully
|
|
@@ -10,6 +10,8 @@ Options:
|
|
|
10
10
|
--session, -s <name> Session name for isolation
|
|
11
11
|
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
|
|
12
12
|
--session-name <uuid> Alias for --session-id
|
|
13
|
+
--append-attempt Run as the next attempt of the existing --session-id execution
|
|
14
|
+
--continues <id> Record that this execution continues an earlier one
|
|
13
15
|
--image <image> Docker image (optional, defaults to OS-matched image)
|
|
14
16
|
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
|
|
15
17
|
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
@@ -64,6 +66,8 @@ Options:
|
|
|
64
66
|
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
65
67
|
--cleanup <id> Remove a Docker execution's containers and snapshot images (never forced)
|
|
66
68
|
--cleanup-dry-run <id> Show the containers and images --cleanup <id> would remove
|
|
69
|
+
--alias <id> <name> Add a name the execution can be addressed by
|
|
70
|
+
--rename <id> <name> Rename the execution, keeping its previous name as an alias
|
|
67
71
|
--version, -v Show version information
|
|
68
72
|
|
|
69
73
|
Examples:
|
|
@@ -91,6 +95,9 @@ Examples:
|
|
|
91
95
|
$ --resume my-docker-session # Restart the stored command in the same container
|
|
92
96
|
$ --resume my-docker-session -- bash # Run a different command in the same container
|
|
93
97
|
$ --resume-all # Re-attach every still-running execution
|
|
98
|
+
$ --session-id <uuid> --append-attempt -- npm test # Next attempt, same log
|
|
99
|
+
$ --continues <uuid> -- npm test # Fresh execution chained to <uuid>
|
|
100
|
+
$ --rename <uuid> nightly-build # Address it as nightly-build from now on
|
|
94
101
|
$ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
|
|
95
102
|
$ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
|
|
96
103
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|