diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 78fd574277f..312469538fd 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -920,6 +920,9 @@ "cache": { "$ref": "#/definitions/targetDefaultsConfig/properties/cache" }, + "sandbox": { + "$ref": "#/definitions/targetDefaultsConfig/properties/sandbox" + }, "syncGenerators": { "$ref": "#/definitions/targetDefaultsConfig/properties/syncGenerators" } @@ -1046,6 +1049,37 @@ "type": "boolean", "description": "Specifies if the given target should be cacheable" }, + "sandbox": { + "type": "object", + "description": "Configures observed-IO sandboxing for tasks of this target", + "properties": { + "...": { + "type": "boolean", + "const": true, + "description": "Merges with the inherited sandbox configuration instead of replacing it. Keys before this token defer to the inherited value; keys after it win." + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether tasks for this target are tracked by the sandbox" + }, + "ignoredReads": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + }, + "ignoredWrites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + } + }, + "additionalProperties": false + }, "syncGenerators": { "type": "array", "items": { diff --git a/packages/nx/schemas/project-schema.json b/packages/nx/schemas/project-schema.json index 07daea57dd9..9f893bddb8a 100644 --- a/packages/nx/schemas/project-schema.json +++ b/packages/nx/schemas/project-schema.json @@ -138,6 +138,37 @@ "type": "boolean", "description": "Specifies if the given target should be cacheable" }, + "sandbox": { + "type": "object", + "description": "Configures observed-IO sandboxing for tasks of this target", + "properties": { + "...": { + "type": "boolean", + "const": true, + "description": "Merges with the inherited sandbox configuration instead of replacing it. Keys before this token defer to the inherited value; keys after it win." + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether tasks for this target are tracked by the sandbox" + }, + "ignoredReads": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + }, + "ignoredWrites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + } + }, + "additionalProperties": false + }, "continuous": { "type": "boolean", "default": false, diff --git a/packages/nx/src/config/task-graph.ts b/packages/nx/src/config/task-graph.ts index edc108c77f2..0ffd03f74fd 100644 --- a/packages/nx/src/config/task-graph.ts +++ b/packages/nx/src/config/task-graph.ts @@ -3,4 +3,10 @@ * `packages/nx/src/native/tasks/types.rs` and exposed to TypeScript via NAPI. * This file re-exports them so existing imports keep working. */ -export type { Task, TaskGraph, TaskTarget, TaskHashDetails } from '../native'; +export type { + Task, + TaskGraph, + TaskTarget, + TaskHashDetails, + TaskSandboxConfiguration, +} from '../native'; diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 39e93a429d2..11bb76395ee 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -1,4 +1,4 @@ -import type { JsonInput } from '../native'; +import type { JsonInput, TaskSandboxConfiguration } from '../native'; import type { PackageJson } from '../utils/package-json'; import type { NxJsonConfiguration, @@ -174,6 +174,14 @@ export interface TargetMetadata { }; } +/** + * Configuration for observed-IO sandboxing of a target's tasks. + * + * The same shape rides on each Task instance (`Task['sandbox']`), so the + * type is shared with the native task definition. + */ +export type TargetSandboxConfiguration = TaskSandboxConfiguration; + export interface TargetDependencyConfig { /** * A list of projects that have `target`. @@ -273,6 +281,11 @@ export interface TargetConfiguration { */ cache?: boolean; + /** + * Configures observed-IO sandboxing for tasks of this target. + */ + sandbox?: TargetSandboxConfiguration; + /** * Metadata about the target */ diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index d5c47373b91..fac22970f00 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -743,6 +743,8 @@ export interface Task { parallelism?: boolean /** This denotes if the task runs continuously */ continuous?: boolean + /** The target's observed-IO sandbox configuration, if declared */ + sandbox?: TaskSandboxConfiguration } /** Graph of Tasks to be executed */ @@ -789,6 +791,26 @@ export interface TaskRun { end: number } +/** Observed-IO sandbox configuration of a task's target */ +export interface TaskSandboxConfiguration { + /** + * Whether tasks for this target are tracked by the sandbox. + * Defaults to true. When false, no IO tracing is reported for the + * task, so no sandbox report is produced. + */ + enabled?: boolean + /** + * Workspace-relative glob patterns for reads that should be excluded + * from sandboxing reports. + */ + ignoredReads?: Array + /** + * Workspace-relative glob patterns for writes that should be excluded + * from sandboxing reports. + */ + ignoredWrites?: Array +} + export declare const enum TaskStatus { Success = 0, Failure = 1, diff --git a/packages/nx/src/native/tasks/types.rs b/packages/nx/src/native/tasks/types.rs index 85648e62514..1fb4c68d657 100644 --- a/packages/nx/src/native/tasks/types.rs +++ b/packages/nx/src/native/tasks/types.rs @@ -38,6 +38,24 @@ pub struct Task { pub parallelism: Option, /// This denotes if the task runs continuously pub continuous: Option, + /// The target's observed-IO sandbox configuration, if declared + pub sandbox: Option, +} + +/// Observed-IO sandbox configuration of a task's target +#[napi(object)] +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct TaskSandboxConfiguration { + /// Whether tasks for this target are tracked by the sandbox. + /// Defaults to true. When false, no IO tracing is reported for the + /// task, so no sandbox report is produced. + pub enabled: Option, + /// Workspace-relative glob patterns for reads that should be excluded + /// from sandboxing reports. + pub ignored_reads: Option>, + /// Workspace-relative glob patterns for writes that should be excluded + /// from sandboxing reports. + pub ignored_writes: Option>, } impl Task { diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts index 092c1da2303..0f4c0109c14 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -441,6 +441,65 @@ describe('target merging', () => { expect(result.cache).not.toBeDefined(); }); }); + + describe('sandbox', () => { + it('should take the base sandbox when the target does not define one', () => { + const result = mergeTargetConfigurations( + { executor: 'nx:run-commands' }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + }); + }); + + it('should replace the base sandbox wholesale when the target defines one', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { ignoredWrites: ['scratch/**'] }, + }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ ignoredWrites: ['scratch/**'] }); + }); + + it('should shallow-merge with the base sandbox via the spread token', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { + '...': true, + ignoredWrites: ['scratch/**'], + } as any, + }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }); + }); + + it('should not be merged for incompatible targets', () => { + const result = mergeTargetConfigurations( + { executor: 'foo' }, + { executor: 'bar', sandbox: { enabled: false } } + ); + expect(result.sandbox).not.toBeDefined(); + }); + }); }); describe('spread syntax in mergeTargetConfigurations', () => { diff --git a/packages/nx/src/tasks-runner/create-task-graph.spec.ts b/packages/nx/src/tasks-runner/create-task-graph.spec.ts index 5f4d12267a5..ab2bb58d3db 100644 --- a/packages/nx/src/tasks-runner/create-task-graph.spec.ts +++ b/packages/nx/src/tasks-runner/create-task-graph.spec.ts @@ -183,6 +183,30 @@ describe('createTaskGraph', () => { }); }); + it('should copy the target sandbox configuration onto the task', () => { + projectGraph.nodes['app1'].data.targets['test'].sandbox = { + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }; + + const taskGraph = createTaskGraph( + projectGraph, + {}, + ['app1', 'lib1'], + ['test'], + undefined, + {} + ); + + expect(taskGraph.tasks['app1:test'].sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }); + expect(taskGraph.tasks['lib1:test'].sandbox).toBeUndefined(); + }); + it('should return tasks with outputs', () => { projectGraph.nodes.app1.data.targets.test.outputs = [ '{workspaceRoot}/dist/app1', diff --git a/packages/nx/src/tasks-runner/create-task-graph.ts b/packages/nx/src/tasks-runner/create-task-graph.ts index fe116baa209..1fa98b29c5d 100644 --- a/packages/nx/src/tasks-runner/create-task-graph.ts +++ b/packages/nx/src/tasks-runner/create-task-graph.ts @@ -407,6 +407,7 @@ export class ProcessTasks { cache: project.data.targets[target].cache ?? false, parallelism: project.data.targets[target].parallelism ?? true, continuous: project.data.targets[target].continuous ?? false, + sandbox: project.data.targets[target].sandbox, }; } diff --git a/packages/nx/src/tasks-runner/task-io-service.spec.ts b/packages/nx/src/tasks-runner/task-io-service.spec.ts new file mode 100644 index 00000000000..a8d143a063c --- /dev/null +++ b/packages/nx/src/tasks-runner/task-io-service.spec.ts @@ -0,0 +1,59 @@ +import { getTaskIOService, TaskPidUpdate } from './task-io-service'; + +describe('TaskIOService sandbox configuration', () => { + it('notifies PID subscribers for tasks without a sandbox configuration', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.notifyPidUpdate({ taskId: 'proj:tracked', pid: 100 }); + + expect(updates).toEqual([{ taskId: 'proj:tracked', pid: 100 }]); + }); + + it('suppresses PID updates for tasks whose sandbox is disabled', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:disabled', { + enabled: false, + }); + service.notifyPidUpdate({ taskId: 'proj:disabled', pid: 200 }); + service.notifyPidUpdate({ taskId: 'proj:other', pid: 201 }); + + expect(updates).toEqual([{ taskId: 'proj:other', pid: 201 }]); + expect(service.isTaskSandboxDisabled('proj:disabled')).toBe(true); + }); + + it('keeps PID updates for a sandbox configuration without enabled: false', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:ignores-only', { + ignoredReads: ['tmp/**'], + }); + service.notifyPidUpdate({ taskId: 'proj:ignores-only', pid: 300 }); + + expect(updates).toEqual([{ taskId: 'proj:ignores-only', pid: 300 }]); + expect(service.isTaskSandboxDisabled('proj:ignores-only')).toBe(false); + }); + + it('re-enables PID updates when a task is re-registered as enabled', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:reenabled', { + enabled: false, + }); + service.notifyPidUpdate({ taskId: 'proj:reenabled', pid: 400 }); + service.registerTaskSandboxConfiguration('proj:reenabled', { + enabled: true, + }); + service.notifyPidUpdate({ taskId: 'proj:reenabled', pid: 401 }); + + expect(updates).toEqual([{ taskId: 'proj:reenabled', pid: 401 }]); + }); +}); diff --git a/packages/nx/src/tasks-runner/task-io-service.ts b/packages/nx/src/tasks-runner/task-io-service.ts index 5ead8ed2409..3d68aff8748 100644 --- a/packages/nx/src/tasks-runner/task-io-service.ts +++ b/packages/nx/src/tasks-runner/task-io-service.ts @@ -1,3 +1,4 @@ +import type { TargetSandboxConfiguration } from '../config/workspace-json-project-json'; import { getProcessMetricsService } from './process-metrics-service'; /** @@ -50,6 +51,34 @@ class TaskIOService { private taskInputCallbacks: TaskInputCallback[] = []; private taskOutputsCallbacks: TaskOutputsCallback[] = []; + /** + * Task IDs whose target opted out of sandboxing via + * `sandbox: { enabled: false }`. PID updates for these tasks are + * suppressed so no IO tracing (and therefore no sandbox report) is + * produced for them. + */ + private sandboxDisabledTaskIds = new Set(); + + /** + * Register the sandbox configuration of a task before it runs. + * Only the disabled state is retained; a task without a registered + * config is treated as sandbox-enabled. + */ + registerTaskSandboxConfiguration( + taskId: string, + config: TargetSandboxConfiguration | undefined + ): void { + if (config?.enabled === false) { + this.sandboxDisabledTaskIds.add(taskId); + } else { + this.sandboxDisabledTaskIds.delete(taskId); + } + } + + isTaskSandboxDisabled(taskId: string): boolean { + return this.sandboxDisabledTaskIds.has(taskId); + } + /** * Subscribe to task PID updates. * Receives notifications when processes are added/removed from tasks. @@ -131,9 +160,13 @@ class TaskIOService { /** * Registers a PID to a task and notifies subscribers. + * No-op for tasks whose target disabled sandboxing. * @param update The TaskPidUpdate containing taskId and pid. */ notifyPidUpdate(update: TaskPidUpdate): void { + if (this.sandboxDisabledTaskIds.has(update.taskId)) { + return; + } for (const cb of this.pidCallbacks) { try { cb(update); diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index 34ebe9bbb20..c5d3307d683 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -59,6 +59,7 @@ import { getTaskSpecificEnv, } from './task-env'; import { TaskStatus } from './tasks-runner'; +import { getTaskIOService } from './task-io-service'; import { Batch, TasksSchedule } from './tasks-schedule'; import { calculateReverseDeps, @@ -414,6 +415,15 @@ export class TaskOrchestrator { // region Processing Scheduled Tasks private async processTask(taskId: string): Promise { const task = this.taskGraph.tasks[taskId]; + if (task.sandbox) { + // Suppresses PID reporting for tasks whose target opted out of + // sandboxing (`sandbox: { enabled: false }`), so no sandbox report is + // produced for them. Both run paths await processTask before spawning. + getTaskIOService().registerTaskSandboxConfiguration( + task.id, + task.sandbox + ); + } const taskSpecificEnv = getTaskSpecificEnv(task, this.projectGraph); if (!task.hash) {