start-command 0.32.0 → 0.33.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 +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +130 -57
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/docker-network-integration.js +22 -14
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +52 -0
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
package/test/failure-handler.js
CHANGED
|
@@ -6,8 +6,56 @@
|
|
|
6
6
|
|
|
7
7
|
const { describe, it } = require('node:test');
|
|
8
8
|
const assert = require('assert');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
9
13
|
const { parseGitUrl, handleFailure } = require('../src/lib/failure-handler');
|
|
10
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Run `createIssue` in a child process whose PATH starts with a fake `gh`
|
|
17
|
+
* recording its argv (NUL-separated). A child process is required because the
|
|
18
|
+
* runtime resolves PATH at startup, so mutating `process.env.PATH` in-process
|
|
19
|
+
* does not affect command lookup under Bun.
|
|
20
|
+
*/
|
|
21
|
+
function createIssueWithFakeGh(fullCommand) {
|
|
22
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-gh-'));
|
|
23
|
+
try {
|
|
24
|
+
const argvFile = path.join(dir, 'argv');
|
|
25
|
+
const ghPath = path.join(dir, 'gh');
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
ghPath,
|
|
28
|
+
[
|
|
29
|
+
'#!/bin/sh',
|
|
30
|
+
`printf '%s\\0' "$@" > "${argvFile}"`,
|
|
31
|
+
'echo https://github.com/owner/repo/issues/1',
|
|
32
|
+
'',
|
|
33
|
+
].join('\n')
|
|
34
|
+
);
|
|
35
|
+
fs.chmodSync(ghPath, 0o755);
|
|
36
|
+
|
|
37
|
+
const modulePath = require.resolve('../src/lib/failure-handler');
|
|
38
|
+
const script = `
|
|
39
|
+
const { createIssue } = require(${JSON.stringify(modulePath)});
|
|
40
|
+
const repo = { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' };
|
|
41
|
+
process.stdout.write(
|
|
42
|
+
String(createIssue(repo, ${JSON.stringify(fullCommand)}, 1, null))
|
|
43
|
+
);
|
|
44
|
+
`;
|
|
45
|
+
const result = execFileSync(process.execPath, ['-e', script], {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
PATH: `${dir}${path.delimiter}${process.env.PATH}`,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const argv = fs.readFileSync(argvFile, 'utf8').split('\0').slice(0, -1);
|
|
53
|
+
return { result, argv };
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
11
59
|
describe('failure-handler', () => {
|
|
12
60
|
describe('parseGitUrl', () => {
|
|
13
61
|
it('should parse HTTPS GitHub URL', () => {
|
|
@@ -82,6 +130,65 @@ describe('failure-handler', () => {
|
|
|
82
130
|
});
|
|
83
131
|
});
|
|
84
132
|
|
|
133
|
+
describe('createIssue', () => {
|
|
134
|
+
// Regression for issue #168: the command was interpolated into a shell
|
|
135
|
+
// string and only `"` was escaped, so a backtick or $(...) in the failing
|
|
136
|
+
// command ran, and every newline of the body arrived as a literal "\\n".
|
|
137
|
+
const runCreateIssue = () =>
|
|
138
|
+
createIssueWithFakeGh('echo "quoted" $(id) `hostname`');
|
|
139
|
+
|
|
140
|
+
it('passes the title and body to gh as separate arguments', () => {
|
|
141
|
+
if (process.platform === 'win32') {
|
|
142
|
+
return; // /bin/sh stub is POSIX-only
|
|
143
|
+
}
|
|
144
|
+
const { result, argv } = runCreateIssue();
|
|
145
|
+
assert.strictEqual(result, 'https://github.com/owner/repo/issues/1');
|
|
146
|
+
assert.deepStrictEqual(argv.slice(0, 4), [
|
|
147
|
+
'issue',
|
|
148
|
+
'create',
|
|
149
|
+
'--repo',
|
|
150
|
+
'owner/repo',
|
|
151
|
+
]);
|
|
152
|
+
assert.strictEqual(argv[4], '--title');
|
|
153
|
+
assert.strictEqual(argv[6], '--body');
|
|
154
|
+
assert.strictEqual(argv.length, 8);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('keeps the failing command verbatim and does not escape it', () => {
|
|
158
|
+
if (process.platform === 'win32') {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const { argv } = runCreateIssue();
|
|
162
|
+
const [title, body] = [argv[5], argv[7]];
|
|
163
|
+
assert.ok(
|
|
164
|
+
title.includes('echo "quoted" $(id) `hostname`'),
|
|
165
|
+
`title must quote the command verbatim, got: ${title}`
|
|
166
|
+
);
|
|
167
|
+
assert.ok(
|
|
168
|
+
body.includes('echo "quoted" $(id) `hostname`'),
|
|
169
|
+
'body must quote the command verbatim'
|
|
170
|
+
);
|
|
171
|
+
assert.ok(
|
|
172
|
+
!title.includes('\\"'),
|
|
173
|
+
'title must not contain escaped quotes'
|
|
174
|
+
);
|
|
175
|
+
assert.ok(!body.includes('\\"'), 'body must not contain escaped quotes');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('writes real newlines into the issue body', () => {
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const { argv } = runCreateIssue();
|
|
183
|
+
const body = argv[7];
|
|
184
|
+
assert.ok(body.includes('\n'), 'body must contain real newlines');
|
|
185
|
+
assert.ok(
|
|
186
|
+
!body.includes('\\n'),
|
|
187
|
+
'body must not contain literal backslash-n sequences'
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
85
192
|
describe('handleFailure', () => {
|
|
86
193
|
it('should return early when disableAutoIssue is true', () => {
|
|
87
194
|
// This should not throw and should return without calling external processes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
loadCommandStream,
|
|
5
|
+
resolveNamedExport,
|
|
6
|
+
} from '../../scripts/load-command-stream.mjs';
|
|
7
|
+
|
|
8
|
+
const $ = () => {};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Namespace shape produced by `import()` of a CommonJS file on Node < 22.12,
|
|
12
|
+
* before `use-m` unwraps it. use-m returns `default` here, so `const { $ }`
|
|
13
|
+
* works.
|
|
14
|
+
*/
|
|
15
|
+
const NODE_20_NAMESPACE = { default: Object.assign($, { $, exec: () => {} }) };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Same file on Node >= 22.12. Node adds a `module.exports` named export when
|
|
19
|
+
* cjs-module-lexer cannot infer the real names, so use-m sees two keys, skips
|
|
20
|
+
* the unwrap and hands the raw namespace to the caller (issue #168).
|
|
21
|
+
*/
|
|
22
|
+
const NODE_24_NAMESPACE = {
|
|
23
|
+
default: Object.assign($, { $, exec: () => {} }),
|
|
24
|
+
'module.exports': Object.assign($, { $, exec: () => {} }),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Shape use-m returns when it did unwrap: the `$` function itself. */
|
|
28
|
+
const UNWRAPPED = Object.assign($, { $, exec: () => {} });
|
|
29
|
+
|
|
30
|
+
describe('load-command-stream', () => {
|
|
31
|
+
it('reproduces the Node 24 namespace that broke the release jobs', () => {
|
|
32
|
+
// Regression guard for the actual failure: destructuring the raw
|
|
33
|
+
// namespace yields undefined, hence "$ is not a function".
|
|
34
|
+
const { $: destructured } = NODE_24_NAMESPACE;
|
|
35
|
+
expect(destructured).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolves $ from the Node >= 22.12 CommonJS namespace', () => {
|
|
39
|
+
expect(typeof resolveNamedExport(NODE_24_NAMESPACE, '$', 'x').$).toBe(
|
|
40
|
+
'function'
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('resolves $ from the Node < 22.12 CommonJS namespace', () => {
|
|
45
|
+
expect(typeof resolveNamedExport(NODE_20_NAMESPACE, '$', 'x').$).toBe(
|
|
46
|
+
'function'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('resolves $ when use-m already unwrapped the module', () => {
|
|
51
|
+
expect(typeof resolveNamedExport(UNWRAPPED, '$', 'x').$).toBe('function');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('resolves $ from an ESM namespace with named exports', () => {
|
|
55
|
+
const namespace = { $, exec: () => {}, default: $ };
|
|
56
|
+
expect(typeof resolveNamedExport(namespace, '$', 'x').$).toBe('function');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws an actionable error instead of "$ is not a function"', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
resolveNamedExport({ nope: 1 }, '$', 'command-stream')
|
|
62
|
+
).toThrow(
|
|
63
|
+
/use\('command-stream'\) did not expose a callable "\$".*keys \[nope\]/s
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reports the received value when use-m resolved nothing', () => {
|
|
68
|
+
expect(() => resolveNamedExport(undefined, '$', 'command-stream')).toThrow(
|
|
69
|
+
/Received undefined/
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('loads command-stream through an injected use() implementation', async () => {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const use = async (name) => {
|
|
76
|
+
calls.push(name);
|
|
77
|
+
return NODE_24_NAMESPACE;
|
|
78
|
+
};
|
|
79
|
+
const module = await loadCommandStream(use);
|
|
80
|
+
expect(calls).toEqual(['command-stream']);
|
|
81
|
+
expect(typeof module.$).toBe('function');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #162.
|
|
3
|
+
*
|
|
4
|
+
* A detached docker session used to be a dead end: once it stopped there was
|
|
5
|
+
* no way to re-enter it, continue it, or run a different command inside the
|
|
6
|
+
* same container, and a supervisor restart left records stuck in "executing"
|
|
7
|
+
* forever. `exitCode 139` with `oomKilled false` also hid the real cause.
|
|
8
|
+
*
|
|
9
|
+
* These tests drive the CLI end to end, so they cover the wiring between the
|
|
10
|
+
* argument parser, the execution store and the new attach/resume verbs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
ExecutionRecord,
|
|
21
|
+
ExecutionStore,
|
|
22
|
+
} = require('../src/lib/execution-store');
|
|
23
|
+
|
|
24
|
+
const TEST_APP_FOLDER = path.join(
|
|
25
|
+
os.tmpdir(),
|
|
26
|
+
`regression-162-${process.pid}-${Date.now()}`
|
|
27
|
+
);
|
|
28
|
+
const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
|
|
29
|
+
const LINO_DB_PATH = path.join(TEST_APP_FOLDER, 'executions.lino');
|
|
30
|
+
|
|
31
|
+
function cleanupTestDir() {
|
|
32
|
+
fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runCli(args) {
|
|
36
|
+
// Reading the store back first is both a precondition check (the record
|
|
37
|
+
// really is persisted) and a guarantee that the CLI child sees it.
|
|
38
|
+
const persisted = fs.existsSync(LINO_DB_PATH)
|
|
39
|
+
? fs.readFileSync(LINO_DB_PATH, 'utf8')
|
|
40
|
+
: '';
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
env: { ...process.env, START_APP_FOLDER: TEST_APP_FOLDER },
|
|
44
|
+
timeout: 20000,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
persisted,
|
|
48
|
+
stdout: result.stdout || '',
|
|
49
|
+
stderr: result.stderr || '',
|
|
50
|
+
exitCode: result.status,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saveRecord(store, overrides = {}) {
|
|
55
|
+
const record = new ExecutionRecord({
|
|
56
|
+
command: overrides.command || 'echo hi',
|
|
57
|
+
pid: overrides.pid || 4242,
|
|
58
|
+
logPath: overrides.logPath || null,
|
|
59
|
+
workingDirectory: '/tmp',
|
|
60
|
+
shell: '/bin/bash',
|
|
61
|
+
options: overrides.options || {},
|
|
62
|
+
});
|
|
63
|
+
if (overrides.exitCode !== undefined) {
|
|
64
|
+
record.complete(overrides.exitCode);
|
|
65
|
+
}
|
|
66
|
+
store.save(record);
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('Issue #162: attach, resume and exit reasons', () => {
|
|
71
|
+
let store;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
cleanupTestDir();
|
|
75
|
+
fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
|
|
76
|
+
store = new ExecutionStore({
|
|
77
|
+
appFolder: TEST_APP_FOLDER,
|
|
78
|
+
useLinks: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
cleanupTestDir();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('--list --running', () => {
|
|
87
|
+
it('reports only executions that are still running', () => {
|
|
88
|
+
const finished = saveRecord(store, {
|
|
89
|
+
command: 'echo finished',
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
});
|
|
92
|
+
const running = saveRecord(store, { command: 'echo running' });
|
|
93
|
+
|
|
94
|
+
const result = runCli(['--list', '--running', '--output-format', 'json']);
|
|
95
|
+
|
|
96
|
+
expect(result.exitCode).toBe(0);
|
|
97
|
+
const parsed = JSON.parse(result.stdout);
|
|
98
|
+
const uuids = parsed.executions.map((record) => record.uuid);
|
|
99
|
+
expect(uuids).toContain(running.uuid);
|
|
100
|
+
expect(uuids).not.toContain(finished.uuid);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is rejected without --list', () => {
|
|
104
|
+
const result = runCli(['--running', '--', 'echo hi']);
|
|
105
|
+
|
|
106
|
+
expect(result.exitCode).not.toBe(0);
|
|
107
|
+
expect(result.stderr).toContain(
|
|
108
|
+
'--running option is only valid with --list'
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('--attach', () => {
|
|
114
|
+
it('fails with a clear error for an unknown identifier', () => {
|
|
115
|
+
const result = runCli(['--attach', 'does-not-exist']);
|
|
116
|
+
|
|
117
|
+
expect(result.exitCode).toBe(1);
|
|
118
|
+
expect(result.stderr).toContain(
|
|
119
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses to attach to a non-isolated execution', () => {
|
|
124
|
+
const record = saveRecord(store, { command: 'echo local' });
|
|
125
|
+
|
|
126
|
+
const result = runCli(['--attach', record.uuid]);
|
|
127
|
+
|
|
128
|
+
expect(result.exitCode).toBe(1);
|
|
129
|
+
expect(result.stderr).toContain(
|
|
130
|
+
'Execution record does not contain an isolation session name'
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('points at --resume when the session is already gone', () => {
|
|
135
|
+
const record = saveRecord(store, {
|
|
136
|
+
command: 'echo gone',
|
|
137
|
+
options: {
|
|
138
|
+
isolated: 'docker',
|
|
139
|
+
isolationMode: 'detached',
|
|
140
|
+
sessionName: 'start-command-162-missing',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = runCli(['--attach', record.uuid]);
|
|
145
|
+
|
|
146
|
+
expect(result.exitCode).toBe(1);
|
|
147
|
+
expect(result.stderr).toContain('--resume');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('--resume', () => {
|
|
152
|
+
it('fails with a clear error for an unknown identifier', () => {
|
|
153
|
+
const result = runCli(['--resume', 'does-not-exist']);
|
|
154
|
+
|
|
155
|
+
expect(result.exitCode).toBe(1);
|
|
156
|
+
expect(result.stderr).toContain(
|
|
157
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('refuses to resume a non-isolated execution', () => {
|
|
162
|
+
const record = saveRecord(store, { command: 'echo local' });
|
|
163
|
+
|
|
164
|
+
const result = runCli(['--resume', record.uuid]);
|
|
165
|
+
|
|
166
|
+
expect(result.exitCode).toBe(1);
|
|
167
|
+
expect(result.stderr).toContain(
|
|
168
|
+
'Execution record does not contain an isolation session name'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('accepts a replacement command after --', () => {
|
|
173
|
+
const result = runCli(['--resume', 'does-not-exist', '--', 'echo', 'hi']);
|
|
174
|
+
|
|
175
|
+
// The identifier is still resolved first, so this reports the missing
|
|
176
|
+
// record rather than treating "echo hi" as a fresh command.
|
|
177
|
+
expect(result.exitCode).toBe(1);
|
|
178
|
+
expect(result.stderr).toContain(
|
|
179
|
+
'No execution found with UUID or session name: does-not-exist'
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('--resume-all', () => {
|
|
185
|
+
it('succeeds with an empty report when nothing is running', () => {
|
|
186
|
+
saveRecord(store, { command: 'echo finished', exitCode: 0 });
|
|
187
|
+
|
|
188
|
+
const result = runCli(['--resume-all', '--output-format', 'json']);
|
|
189
|
+
|
|
190
|
+
expect(result.exitCode).toBe(0);
|
|
191
|
+
const parsed = JSON.parse(result.stdout);
|
|
192
|
+
expect(parsed.count).toBe(0);
|
|
193
|
+
expect(parsed.executions).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('reconciles executions whose session no longer exists', () => {
|
|
197
|
+
const logPath = path.join(TEST_APP_FOLDER, 'orphan.log');
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
logPath,
|
|
200
|
+
[
|
|
201
|
+
'working...',
|
|
202
|
+
'==================================================',
|
|
203
|
+
'Finished: 2026-09-03 10:00:00.000',
|
|
204
|
+
'Exit Code: 0',
|
|
205
|
+
'',
|
|
206
|
+
].join('\n'),
|
|
207
|
+
'utf8'
|
|
208
|
+
);
|
|
209
|
+
const record = saveRecord(store, {
|
|
210
|
+
command: 'echo orphan',
|
|
211
|
+
logPath,
|
|
212
|
+
options: {
|
|
213
|
+
isolated: 'docker',
|
|
214
|
+
isolationMode: 'detached',
|
|
215
|
+
sessionName: 'start-command-162-orphan',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = runCli(['--resume-all', '--output-format', 'json']);
|
|
220
|
+
|
|
221
|
+
expect(result.exitCode).toBe(0);
|
|
222
|
+
const parsed = JSON.parse(result.stdout);
|
|
223
|
+
expect(parsed.count).toBe(1);
|
|
224
|
+
expect(parsed.executions[0].uuid).toBe(record.uuid);
|
|
225
|
+
expect(parsed.executions[0].action).toBe('reconciled');
|
|
226
|
+
|
|
227
|
+
// The stuck "executing" record is now finalized in the store.
|
|
228
|
+
const reloaded = new ExecutionStore({
|
|
229
|
+
appFolder: TEST_APP_FOLDER,
|
|
230
|
+
useLinks: false,
|
|
231
|
+
}).get(record.uuid);
|
|
232
|
+
expect(reloaded.status).toBe('executed');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('exit reason hint', () => {
|
|
237
|
+
it('explains a 139 exit caused by heap exhaustion', () => {
|
|
238
|
+
const logPath = path.join(TEST_APP_FOLDER, 'oom.log');
|
|
239
|
+
fs.writeFileSync(
|
|
240
|
+
logPath,
|
|
241
|
+
[
|
|
242
|
+
'building...',
|
|
243
|
+
'',
|
|
244
|
+
'<--- Last few GCs --->',
|
|
245
|
+
'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
|
|
246
|
+
'',
|
|
247
|
+
'==================================================',
|
|
248
|
+
'Finished: 2026-09-03 10:00:00.000',
|
|
249
|
+
'Exit Code: 139',
|
|
250
|
+
'',
|
|
251
|
+
].join('\n'),
|
|
252
|
+
'utf8'
|
|
253
|
+
);
|
|
254
|
+
const record = saveRecord(store, {
|
|
255
|
+
command: 'bun run build',
|
|
256
|
+
logPath,
|
|
257
|
+
exitCode: 139,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = runCli([
|
|
261
|
+
'--status',
|
|
262
|
+
record.uuid,
|
|
263
|
+
'--output-format',
|
|
264
|
+
'json',
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
expect(result.exitCode).toBe(0);
|
|
268
|
+
const parsed = JSON.parse(result.stdout);
|
|
269
|
+
expect(parsed.exitCode).toBe(139);
|
|
270
|
+
expect(parsed.oomKilled).toBeUndefined();
|
|
271
|
+
expect(parsed.exitReason).toBe('memory-exhaustion (v8-heap-limit)');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('does not invent a reason for a clean exit', () => {
|
|
275
|
+
const logPath = path.join(TEST_APP_FOLDER, 'clean.log');
|
|
276
|
+
fs.writeFileSync(logPath, 'all good\n', 'utf8');
|
|
277
|
+
const record = saveRecord(store, {
|
|
278
|
+
command: 'echo ok',
|
|
279
|
+
logPath,
|
|
280
|
+
exitCode: 0,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = runCli([
|
|
284
|
+
'--status',
|
|
285
|
+
record.uuid,
|
|
286
|
+
'--output-format',
|
|
287
|
+
'json',
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
expect(result.exitCode).toBe(0);
|
|
291
|
+
const parsed = JSON.parse(result.stdout);
|
|
292
|
+
expect(parsed.exitReason).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('session identity across resumes', () => {
|
|
297
|
+
it('keeps addressing one logical session by its previous name', () => {
|
|
298
|
+
const record = saveRecord(store, {
|
|
299
|
+
command: 'echo resumed',
|
|
300
|
+
options: {
|
|
301
|
+
isolated: 'docker',
|
|
302
|
+
isolationMode: 'detached',
|
|
303
|
+
sessionName: 'start-command-162-b',
|
|
304
|
+
sessionNameHistory: ['start-command-162-a'],
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = runCli([
|
|
309
|
+
'--status',
|
|
310
|
+
'start-command-162-a',
|
|
311
|
+
'--output-format',
|
|
312
|
+
'json',
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
expect(result.exitCode).toBe(0);
|
|
316
|
+
const parsed = JSON.parse(result.stdout);
|
|
317
|
+
expect(parsed.uuid).toBe(record.uuid);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|