Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
09489c4
chore(core): re-run CI
AgentEnder Jul 24, 2026
2266dc9
fix(core): keep nx release publish from collapsing its own output [Se…
nx-cloud[bot] Jul 25, 2026
017017d
docs(core): qualify the atLineStart invariant as class-local [Self-He…
nx-cloud[bot] Jul 28, 2026
22fd3a6
fix(core): stop the failures-only default from suppressing task strea…
nx-cloud[bot] Aug 21, 2026
7179235
feat(core): always leave a task's terminal output on disk
AgentEnder Jul 29, 2026
9a7caeb
feat(core): collect terminal outputs that have no cache entry
AgentEnder Jul 29, 2026
f945e0d
feat(core): add an output style that addresses task logs instead of p…
AgentEnder Aug 19, 2026
9821201
fix(core): keep the batch fold out of a summary run
AgentEnder Aug 24, 2026
d77920d
fix(core): stop summary printing task output through the paths the fo…
AgentEnder Aug 24, 2026
6c0c0a1
fix(core): do not address a log file that was never written
AgentEnder Aug 24, 2026
2ce1566
fix(core): expect the exit code a life cycle can actually see
AgentEnder Aug 25, 2026
30db6f7
fix(core): stop the persistence tests inheriting NX_CACHE_FAILURES
AgentEnder Aug 25, 2026
cd1ae07
fix(core): capture batch output the summary style will not print
AgentEnder Aug 25, 2026
f523367
fix(core): carry the output style as what was asked for and what was …
AgentEnder Aug 25, 2026
32fa07b
cleanup(core): drop a choices guard that can no longer fire
AgentEnder Aug 25, 2026
c983ef4
chore(core): move the agent-output specs to vitest
AgentEnder Aug 26, 2026
992255b
chore(core): move the agent-output specs to vitest [Self-Healing CI R…
nx-cloud[bot] Aug 27, 2026
b404518
fix(core): honour the resolved output style at the call sites that re…
AgentEnder Aug 28, 2026
5706182
fix(core): stop the batch crash path duplicating its worker log per task
AgentEnder Aug 28, 2026
0eda83e
docs(core): correct four comments this stack left false
AgentEnder Aug 28, 2026
9f5c6df
fix(core): keep the batch capture on the same file and retry a failed…
AgentEnder Aug 28, 2026
9012886
fix(core): keep the batch capture on the same file and retry a failed…
nx-cloud[bot] Sep 2, 2026
a81db89
fix(core): give a reporting batch's worker log the same handoff as a …
AgentEnder Sep 2, 2026
3f4c8c8
fix(core): stop the batch worker log pointer naming a hash that is ab…
AgentEnder Sep 2, 2026
ef27aa9
fix(core): keep a stopped batch's partial log under summary
AgentEnder Sep 2, 2026
54a7dba
fix(core): keep a stopped batch's partial log under summary [Self-Hea…
nx-cloud[bot] Sep 2, 2026
62bb6cd
cleanup(core): name the cache-entry flag for what it means
FrozenPandaz Sep 9, 2026
a4c6cb2
docs(core): correct what the init-tasks-runner style comment claims
FrozenPandaz Sep 9, 2026
5144fa3
cleanup(core): let a write stream own the batch capture file
FrozenPandaz Sep 9, 2026
789fabd
cleanup(core): apply backpressure the capture only claimed to have
FrozenPandaz Sep 9, 2026
205bf9b
feat(core): make the batch worker log its own artifact
FrozenPandaz Sep 9, 2026
da069d1
docs(core): say which path re-mints a batch id
FrozenPandaz Sep 9, 2026
b803fda
fix(core): stop the batch-log size sweep evicting a live batch's log
FrozenPandaz Sep 9, 2026
71ca791
fix(core): stop a failed batch capture wedging the run
FrozenPandaz Sep 9, 2026
7dc6614
fix(core): make the batch acceptance test able to fail
FrozenPandaz Sep 9, 2026
2e4cbe8
fix(core): make the batch acceptance test able to fail [Self-Healing …
nx-cloud[bot] Sep 9, 2026
ed911ac
cleanup(core): move the batch-log sweep into the native cache
FrozenPandaz Sep 9, 2026
b6fac95
cleanup(core): stat the continuous task's log once, not twice
FrozenPandaz Sep 9, 2026
be806ec
fix(core): resume the worker on every path the capture stops
FrozenPandaz Sep 9, 2026
288475b
chore(core): give the batch-log sweep real coverage
FrozenPandaz Sep 10, 2026
21ab04c
fix(core): stop a failed or paused capture losing the worker's output
AgentEnder Sep 11, 2026
88182ba
fix(core): no-op the batch-log sweep where its native half does not e…
AgentEnder Sep 11, 2026
8ec10f9
fix(core): do not announce a batch log with nothing in it
AgentEnder Sep 11, 2026
b49dd9b
fix(core): stop addressing a task log that was written empty
AgentEnder Sep 11, 2026
ffa5c74
fix(core): stop the batch-log sweep following a symlinked directory
AgentEnder Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions e2e/nx/src/terminal-outputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { existsSync, readFileSync } from 'fs';
import {
cleanupProject,
listFiles,
newProject,
readFile,
runCLI,
uniq,
updateFile,
} from '@nx/e2e-utils';

const TERMINAL_OUTPUTS_DIR = '.nx/cache/terminalOutputs';

describe('terminal outputs on disk', () => {
beforeAll(() => newProject({ packages: [] }));

afterAll(() => cleanupProject());

/**
* Every task that reaches a terminal state has to leave its output at
* `<cacheDir>/terminalOutputs/<hash>`, so the file is found by content
* rather than by recomputing the task's hash.
*/
function terminalOutputContains(marker: string): boolean {
return listFiles(TERMINAL_OUTPUTS_DIR).some((file) =>
readFile(`${TERMINAL_OUTPUTS_DIR}/${file}`).includes(marker)
);
}

function createRunCommandsProject(
lib: string,
marker: string,
cache: boolean
) {
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: 'nx:run-commands',
cache,
options: { command: `node -e "console.log('${marker}')"` },
},
},
})
);
}

it('should write terminal output for a cache:false task with --output-style=stream', () => {
const lib = uniq('streamed');
const marker = `streamed-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=stream`);

expect(results).toContain(marker);
expect(terminalOutputContains(marker)).toBe(true);
}, 120000);

it('should write terminal output for a cache:false task in batch mode', () => {
const plugin = uniq('batch-plugin');
const lib = uniq('batched');
const marker = `batched-marker-${lib}`;

// A minimal plugin whose executor supports batching, dropped straight into
// node_modules so it resolves like any installed one. Its batch
// implementation hands each task's output back over IPC and never touches
// disk — exactly the path that used to leave no file behind.
const pluginRoot = `node_modules/${plugin}`;
updateFile(
`${pluginRoot}/package.json`,
JSON.stringify({
name: plugin,
version: '0.0.1',
executors: './executors.json',
})
);
updateFile(
`${pluginRoot}/executors.json`,
JSON.stringify({
executors: {
echo: {
implementation: './impl',
batchImplementation: './batch-impl',
schema: './schema.json',
},
},
})
);
updateFile(
`${pluginRoot}/schema.json`,
JSON.stringify({
$schema: 'http://json-schema.org/schema',
type: 'object',
properties: { text: { type: 'string' } },
})
);
updateFile(
`${pluginRoot}/impl.js`,
`module.exports = {
default: async (options) => {
// Distinct from the batch marker on purpose: batching falls back to
// this silently when the batch implementation does not resolve
// (tasks-schedule.ts returns without a diagnostic), and the
// single-task path already wrote the output file before this PR - so
// a shared marker would keep the test green on the unfixed code.
console.log(options.text + '-single');
return { success: true };
},
};`
);
updateFile(
`${pluginRoot}/batch-impl.js`,
`module.exports = {
default: async (taskGraph, inputs) => {
const results = {};
for (const taskId of Object.keys(taskGraph.tasks)) {
results[taskId] = {
success: true,
terminalOutput: inputs[taskId].text + '-batched',
};
}
return results;
},
};`
);

updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: `${plugin}:echo`,
cache: false,
options: { text: marker },
},
},
})
);

runCLI(`echo ${lib}`, { env: { NX_BATCH_MODE: 'true' } });

// The batched marker specifically: this is the path NXC-4694 is about, and
// the non-batch fallback wrote a file at base too.
expect(terminalOutputContains(`${marker}-batched`)).toBe(true);
}, 120000);

it('should not replay a task whose output was written without artifacts', () => {
const lib = uniq('skipcache');
const marker = `skipcache-marker-${lib}`;
createRunCommandsProject(lib, marker, true);

// --skip-nx-cache leaves a terminal output file, and a record of it so the
// GC can collect it, but writes no cache entry.
const skipped = runCLI(`echo ${lib} --skip-nx-cache`);
expect(skipped).not.toContain('read the output from the cache');
expect(terminalOutputContains(marker)).toBe(true);

// That record must never be served as a hit — there are no outputs behind
// it, so replaying it would restore nothing while reporting success.
const firstRealRun = runCLI(`echo ${lib}`);
expect(firstRealRun).not.toContain('read the output from the cache');

// ...and the real entry it just wrote supersedes the record.
const replay = runCLI(`echo ${lib}`);
expect(replay).toContain('read the output from the cache');
}, 120000);

describe('--output-style=summary', () => {
function createFailingProject(lib: string, marker: string) {
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: 'nx:run-commands',
cache: false,
options: {
command: `node -e "console.log('${marker}'); process.exit(3)"`,
},
},
},
})
);
}

function nonEmptyLines(out: string): string[] {
return out.split('\n').filter((l) => l.trim().length > 0);
}

it('should collapse a passing run to a handful of lines', () => {
const lib = uniq('summary-pass');
const marker = `summary-pass-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=summary`);

// The task's own output is on disk, not in the run's output.
expect(results).not.toContain(marker);
expect(results).toContain('succeeded');
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(10);
}, 120000);

it('should name a failing task and point at its log on disk', () => {
const lib = uniq('summary-fail');
const marker = `summary-fail-marker-${lib}`;
createFailingProject(lib, marker);

const results = runCLI(`echo ${lib} --output-style=summary`, {
silenceError: true,
redirectStderr: true,
});

expect(results).toContain('1 failed');
expect(results).toContain(`nx run ${lib}:echo`);
// No exit code is printed. `completeTasks` rebuilds `TaskResult.code`
// from the status rather than carrying the code the process returned, so
// every failure reaches a life cycle as 1 - this task exits 3 and would
// have rendered `(exit 1)`. Rather than print a number that is always 1
// to a reader whose job is to machine-read the line, the style prints
// none. Carrying the real code through `TaskResult` would change what
// task history and Nx Cloud record, which is its own change.
expect(results).not.toContain('(exit');
// Bounded regardless of how much the task logged.
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(30);

// The path it prints has to be real, and hold the output it stands in for.
const logPath = results.match(/full log: (\S+)/)?.[1];
expect(logPath).toBeDefined();
expect(existsSync(logPath)).toBe(true);
expect(readFileSync(logPath, 'utf-8')).toContain(marker);
}, 120000);

it('should be the default when nx is driven by an AI agent', () => {
const lib = uniq('summary-agent');
const marker = `summary-agent-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib}`, { env: { CLAUDECODE: '1' } });

expect(results).not.toContain(marker);
expect(results).toContain('succeeded');
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(10);
}, 120000);

it('should let an explicit output style beat the AI agent default', () => {
const lib = uniq('summary-explicit');
const marker = `summary-explicit-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=static`, {
env: { CLAUDECODE: '1' },
});

// static prints every task in full, agent or not.
expect(results).toContain(marker);
}, 120000);
});

it('should keep the cached path intact so a replay still reads its output', () => {
const lib = uniq('cached');
const marker = `cached-marker-${lib}`;
createRunCommandsProject(lib, marker, true);

const firstRun = runCLI(`echo ${lib}`);
expect(firstRun).not.toContain('read the output from the cache');
expect(terminalOutputContains(marker)).toBe(true);

const replay = runCLI(`echo ${lib}`);
expect(replay).toContain('read the output from the cache');
// The replay reads this very file, so it must still be there afterwards.
expect(terminalOutputContains(marker)).toBe(true);
}, 120000);
});
14 changes: 14 additions & 0 deletions e2e/utils/get-env-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ export function getStrippedEnvironmentVariables(cwd: string = tmpProjPath()) {
// Remove AI agent detection env vars to prevent the test runner's
// environment (e.g., running inside Claude Code) from leaking into
// e2e test subprocesses. Tests that need these pass them explicitly.
//
// Every variable `is_ai_agent` reads must be here, or a run started from
// that agent resolves to `--output-style=summary`, which prints no task
// output and no `Successfully ran target` line — the string ~96 e2e files
// assert on. This list has fallen behind `ai.rs` twice as it gained
// detectors, so check it rather than trusting it:
// grep -o 'env::var("[^"]*"' packages/nx/src/native/utils/ai.rs
// PAGER is deliberately absent: Cursor needs it alongside CURSOR_TRACE_ID
// and COMPOSER_NO_INTERACTION, which are both stripped. NX_DAEMON_PROCESS
// is already dropped by the NX_ rule above.
const aiAgentEnvVars = [
'CLAUDECODE',
'CLAUDE_CODE',
Expand All @@ -196,6 +206,10 @@ export function getStrippedEnvironmentVariables(cwd: string = tmpProjPath()) {
'CURSOR_TRACE_ID',
'COMPOSER_NO_INTERACTION',
'REPL_ID',
'VSCODE_AGENT',
'CODEX_THREAD_ID',
'COPILOT_CLI',
'SUPERSET_AGENT_ID',
];
if (aiAgentEnvVars.includes(key)) {
return false;
Expand Down
15 changes: 11 additions & 4 deletions packages/nx/src/command-line/release/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,19 +307,26 @@ async function runPublishOnProjects(
* NOTE: Force TUI to be disabled for now.
*/
process.env.NX_TUI = 'false';
const publishOutputStyle = (args as any).specifiedOutputStyle ?? 'static';
const { taskResults } = await runCommandForTasks(
projectsWithTarget,
projectGraph,
{ nxJson },
{
targets: [requiredTargetName],
...(args as any),
// Everything this command reports — the registry, the tag, the
// package.json diff, the dry-run summary — is printed from inside the
// task, so the failures-only default would swallow all of it (under
// --dry-run every task succeeds by definition). An explicit
// --output-style still wins, since it comes in through the spread.
outputStyle: 'static',
...(args as any),
// --dry-run every task succeeds by definition). Set after the spread and
// on all three fields: the middleware always writes `resolvedOutputStyle`,
// so a value placed before it would be overwritten on every run, and the
// renderer reads the new fields rather than the deprecated one. A style
// the user actually named still wins, which is what `specifiedOutputStyle`
// being set means.
outputStyle: publishOutputStyle,
specifiedOutputStyle: publishOutputStyle,
resolvedOutputStyle: publishOutputStyle,
// It is possible for workspaces to have circular dependencies between packages and still release them to a registry
nxIgnoreCycles: true,
},
Expand Down
Loading
Loading